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
@@ -0,0 +1,236 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconBox,
IconChevronDown,
IconCode,
IconEye,
IconNorthStar,
IconSettings,
IconTable,
useIcons,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
const WEBHOOK_ENTITY_DROPDOWN_ID = 'webhook-entity-select';
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
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 (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
disableClickForClickableComponent={disabled}
onClose={() => setSearchInput('')}
clickableComponent={
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>{getSelectedLabel()}</StyledControlLabel>
<StyledControlIconChevronDown
disabled={disabled}
size={theme.icon.size.md}
/>
</StyledControlContainer>
}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<DropdownMenuSearchInput
autoFocus
value={searchInput}
placeholder={t`Search...`}
onChange={(event) => setSearchInput(event.target.value)}
/>
<DropdownMenuSeparator />
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={selectableItemIds}
focusId={dropdownId}
>
<DropdownMenuItemsContainer hasMaxHeight>
{shouldShowObjects && (
<>
<DropdownMenuSectionLabel label={t`Core Objects`} />
{filteredObjectOptions.map((option) => (
<SelectableListItem
key={option.value}
itemId={option.value}
onEnter={() => handleSelect(option.value)}
>
<MenuItemSelect
LeftIcon={option.Icon}
text={option.label}
selected={value === option.value}
focused={selectedItemId === option.value}
onClick={() => handleSelect(option.value)}
/>
</SelectableListItem>
))}
</>
)}
{shouldShowSeparator && <DropdownMenuSeparator />}
{shouldShowMetadata && (
<>
<DropdownMenuSectionLabel label={t`Metadata`} />
{filteredMetadataOptions.map((option) => (
<SelectableListItem
key={option.value}
itemId={option.value}
onEnter={() => handleSelect(option.value)}
>
<MenuItemSelect
LeftIcon={option.icon}
text={option.label}
selected={value === option.value}
focused={selectedItemId === option.value}
onClick={() => handleSelect(option.value)}
/>
</SelectableListItem>
))}
</>
)}
</DropdownMenuItemsContainer>
</SelectableList>
</DropdownContent>
}
/>
);
};
@@ -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: '',
});
});
@@ -32,7 +32,7 @@ type UseWebhookFormProps = {
const DEFAULT_FORM_VALUES: WebhookFormValues = {
targetUrl: '',
description: '',
operations: [{ object: '*', action: '*' }],
operations: addEmptyOperationIfNecessary([{ object: '*', action: '*' }]),
secret: '',
};
@@ -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 };
});
};