13c2234856
## 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>
213 lines
5.9 KiB
TypeScript
213 lines
5.9 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useForm } from 'react-hook-form';
|
|
|
|
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
|
|
import { addEmptyOperationIfNecessary } from '@/settings/developers/utils/addEmptyOperationIfNecessary';
|
|
import {
|
|
createWebhookCreateInput,
|
|
createWebhookUpdateInput,
|
|
} from '@/settings/developers/utils/createWebhookInput';
|
|
import { parseOperationsFromStrings } from '@/settings/developers/utils/parseOperationsFromStrings';
|
|
import {
|
|
webhookFormSchema,
|
|
type WebhookFormValues,
|
|
} from '@/settings/developers/validation-schemas/webhookFormSchema';
|
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
import { ApolloError } from '@apollo/client';
|
|
import { t } from '@lingui/core/macro';
|
|
import { SettingsPath } from 'twenty-shared/types';
|
|
import {
|
|
useCreateWebhookMutation,
|
|
useDeleteWebhookMutation,
|
|
useGetWebhookQuery,
|
|
useUpdateWebhookMutation,
|
|
} from '~/generated-metadata/graphql';
|
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
|
|
|
type UseWebhookFormProps = {
|
|
webhookId?: string;
|
|
mode: WebhookFormMode;
|
|
};
|
|
|
|
const DEFAULT_FORM_VALUES: WebhookFormValues = {
|
|
targetUrl: '',
|
|
description: '',
|
|
operations: addEmptyOperationIfNecessary([{ object: '*', action: '*' }]),
|
|
secret: '',
|
|
};
|
|
|
|
export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
|
|
const navigate = useNavigateSettings();
|
|
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
|
|
|
const isCreationMode = mode === WebhookFormMode.Create;
|
|
|
|
const [createWebhook] = useCreateWebhookMutation();
|
|
const [updateWebhook] = useUpdateWebhookMutation();
|
|
const [deleteWebhook] = useDeleteWebhookMutation();
|
|
|
|
const formConfig = useForm<WebhookFormValues>({
|
|
mode: isCreationMode ? 'onSubmit' : 'onTouched',
|
|
resolver: zodResolver(webhookFormSchema),
|
|
defaultValues: DEFAULT_FORM_VALUES,
|
|
});
|
|
|
|
const { loading, error } = useGetWebhookQuery({
|
|
skip: isCreationMode || !webhookId,
|
|
variables: {
|
|
id: webhookId || '',
|
|
},
|
|
onCompleted: (data) => {
|
|
const webhook = data.webhook;
|
|
if (!webhook) return;
|
|
|
|
const baseOperations = webhook?.operations?.length
|
|
? parseOperationsFromStrings(webhook.operations)
|
|
: [];
|
|
const operations = addEmptyOperationIfNecessary(baseOperations);
|
|
|
|
formConfig.reset({
|
|
targetUrl: webhook.targetUrl || '',
|
|
description: webhook.description || '',
|
|
operations,
|
|
secret: webhook.secret || '',
|
|
});
|
|
},
|
|
onError: () => {
|
|
enqueueErrorSnackBar({
|
|
message: t`Failed to load webhook`,
|
|
});
|
|
},
|
|
});
|
|
|
|
const { isDirty, isValid, isSubmitting } = formConfig.formState;
|
|
const canSave = isCreationMode
|
|
? isValid && !isSubmitting
|
|
: isDirty && isValid && !isSubmitting;
|
|
|
|
const handleCreate = async (formValues: WebhookFormValues) => {
|
|
try {
|
|
const input = createWebhookCreateInput(formValues);
|
|
const { data } = await createWebhook({ variables: { input } });
|
|
const createdWebhook = data?.createWebhook;
|
|
|
|
const targetUrl = createdWebhook?.targetUrl
|
|
? `${createdWebhook?.targetUrl}`
|
|
: '';
|
|
|
|
enqueueSuccessSnackBar({
|
|
message: t`Webhook ${targetUrl} created successfully`,
|
|
});
|
|
|
|
navigate(
|
|
createdWebhook ? SettingsPath.WebhookDetail : SettingsPath.ApiWebhooks,
|
|
createdWebhook ? { webhookId: createdWebhook.id } : undefined,
|
|
);
|
|
} catch (error) {
|
|
enqueueErrorSnackBar({
|
|
apolloError: error instanceof ApolloError ? error : undefined,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async (formValues: WebhookFormValues) => {
|
|
if (!webhookId) {
|
|
enqueueErrorSnackBar({
|
|
message: t`Webhook ID is required for updates`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const input = createWebhookUpdateInput(formValues);
|
|
const { data } = await updateWebhook({
|
|
variables: { input: { id: webhookId, update: input } },
|
|
});
|
|
const updatedWebhook = data?.updateWebhook;
|
|
|
|
formConfig.reset(formValues);
|
|
|
|
const targetUrl = updatedWebhook?.targetUrl
|
|
? `${updatedWebhook.targetUrl}`
|
|
: '';
|
|
|
|
enqueueSuccessSnackBar({
|
|
message: t`Webhook ${targetUrl} updated successfully`,
|
|
});
|
|
} catch (error) {
|
|
enqueueErrorSnackBar({
|
|
apolloError: error instanceof ApolloError ? error : undefined,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSave = isCreationMode ? handleCreate : handleUpdate;
|
|
|
|
const updateOperation = (
|
|
index: number,
|
|
field: 'object' | 'action',
|
|
value: string | null,
|
|
) => {
|
|
const currentOperations = formConfig.getValues('operations');
|
|
const newOperations = [...currentOperations];
|
|
|
|
newOperations[index] = {
|
|
...newOperations[index],
|
|
[field]: value,
|
|
};
|
|
|
|
formConfig.setValue(
|
|
'operations',
|
|
addEmptyOperationIfNecessary(newOperations),
|
|
{ shouldDirty: true, shouldValidate: true },
|
|
);
|
|
};
|
|
|
|
const removeOperation = (index: number) => {
|
|
const currentOperations = formConfig.getValues('operations');
|
|
const newOperations = currentOperations.filter((_, i) => i !== index);
|
|
|
|
formConfig.setValue(
|
|
'operations',
|
|
addEmptyOperationIfNecessary(newOperations),
|
|
{ shouldDirty: true, shouldValidate: true },
|
|
);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!webhookId) {
|
|
enqueueErrorSnackBar({
|
|
message: t`Webhook ID is required for deletion`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteWebhook({
|
|
variables: { id: webhookId },
|
|
});
|
|
enqueueSuccessSnackBar({
|
|
message: t`Webhook deleted successfully`,
|
|
});
|
|
|
|
navigate(SettingsPath.ApiWebhooks);
|
|
} catch (error) {
|
|
enqueueErrorSnackBar({
|
|
apolloError: error instanceof ApolloError ? error : undefined,
|
|
});
|
|
}
|
|
};
|
|
|
|
return {
|
|
formConfig,
|
|
loading,
|
|
canSave,
|
|
handleSave,
|
|
updateOperation,
|
|
removeOperation,
|
|
handleDelete,
|
|
isCreationMode,
|
|
error,
|
|
};
|
|
};
|