diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts
index a54888f1d3ce8..9c9c5a779c984 100644
--- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts
+++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts
@@ -542,6 +542,21 @@ describe('updateRuleDataSchema', () => {
expect(result).toEqual({ metadata: { name: 'updated name' } });
});
+ it('accepts an enabled field set to true', () => {
+ const result = updateRuleDataSchema.parse({ enabled: true });
+ expect(result).toEqual({ enabled: true });
+ });
+
+ it('accepts an enabled field set to false', () => {
+ const result = updateRuleDataSchema.parse({ enabled: false });
+ expect(result).toEqual({ enabled: false });
+ });
+
+ it('omits enabled when not provided', () => {
+ const result = updateRuleDataSchema.parse({});
+ expect(result).not.toHaveProperty('enabled');
+ });
+
it('accepts a state_transition object', () => {
const result = updateRuleDataSchema.parse({
state_transition: { pending_count: 3, recovering_count: 5 },
diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts
index bb1e32158f2d3..9a361df9afe0a 100644
--- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts
+++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts
@@ -250,6 +250,7 @@ export const updateRuleDataSchema = z
grouping: groupingSchema.optional().nullable(),
no_data: noDataSchema.optional().nullable(),
notification_policies: z.array(notificationPolicyRefSchema).optional().nullable(),
+ enabled: z.boolean().optional().describe('Whether the rule is enabled.'),
})
.strip();
diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts
new file mode 100644
index 0000000000000..fd1219426673a
--- /dev/null
+++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMutation, useQueryClient } from '@kbn/react-query';
+import { i18n } from '@kbn/i18n';
+import { useService, CoreStart } from '@kbn/core-di-browser';
+import { RulesApi } from '../services/rules_api';
+import { ruleKeys } from './query_key_factory';
+
+export const useToggleRuleEnabled = () => {
+ const rulesApi = useService(RulesApi);
+ const { toasts } = useService(CoreStart('notifications'));
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
+ rulesApi.updateRule(id, { enabled }),
+ onSuccess: (_data, variables) => {
+ toasts.addSuccess(
+ variables.enabled
+ ? i18n.translate('xpack.alertingV2.hooks.useToggleRuleEnabled.enabledMessage', {
+ defaultMessage: 'Rule enabled',
+ })
+ : i18n.translate('xpack.alertingV2.hooks.useToggleRuleEnabled.disabledMessage', {
+ defaultMessage: 'Rule disabled',
+ })
+ );
+ queryClient.invalidateQueries(ruleKeys.lists());
+ queryClient.invalidateQueries(ruleKeys.detail(variables.id));
+ },
+ onError: () => {
+ toasts.addDanger(
+ i18n.translate('xpack.alertingV2.hooks.useToggleRuleEnabled.errorMessage', {
+ defaultMessage: 'Failed to update rule status',
+ })
+ );
+ },
+ });
+};
diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx
index c69a814da981d..814e6b2eae127 100644
--- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx
+++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx
@@ -87,6 +87,20 @@ const renderEditPage = (ruleId: string = 'rule-1') => {
);
};
+const renderClonePage = (sourceRuleId: string = 'rule-1') => {
+ return render(
+
+
+
+
+
+
+
+
+
+ );
+};
+
describe('RuleFormPage', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -272,4 +286,186 @@ describe('RuleFormPage', () => {
expect(capturedStandaloneProps.query).toBe('FROM metrics-* | LIMIT 10');
});
});
+
+ describe('clone mode', () => {
+ it('shows loading spinner while fetching source rule', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ });
+
+ renderClonePage();
+
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ it('shows error state when source rule fetch fails', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Rule not found'),
+ });
+
+ renderClonePage();
+
+ expect(screen.getByText('Failed to load source rule for cloning')).toBeInTheDocument();
+ expect(screen.getByText('Rule not found')).toBeInTheDocument();
+ });
+
+ it('renders the "Clone rule" page title', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: {
+ id: 'rule-1',
+ kind: 'alert',
+ enabled: true,
+ metadata: { name: 'Source Rule' },
+ time_field: '@timestamp',
+ schedule: { every: '1m', lookback: '5m' },
+ evaluation: { query: { base: 'FROM logs-* | LIMIT 1' } },
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderClonePage();
+
+ expect(screen.getByRole('heading', { name: 'Clone rule' })).toBeInTheDocument();
+ });
+
+ it('does not pass ruleId to StandaloneRuleForm (creates a new rule)', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: {
+ id: 'rule-1',
+ kind: 'alert',
+ enabled: true,
+ metadata: { name: 'Source Rule' },
+ time_field: '@timestamp',
+ schedule: { every: '1m', lookback: '5m' },
+ evaluation: { query: { base: 'FROM logs-* | LIMIT 1' } },
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderClonePage();
+
+ expect(capturedStandaloneProps.ruleId).toBeUndefined();
+ });
+
+ it('appends " (clone)" to the rule name in initialValues', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: {
+ id: 'rule-1',
+ kind: 'alert',
+ enabled: true,
+ metadata: { name: 'My Alert Rule', labels: ['prod'], owner: 'team-a' },
+ time_field: '@timestamp',
+ schedule: { every: '10m', lookback: '2m' },
+ evaluation: {
+ query: {
+ base: 'FROM logs-* | STATS count() BY host.name',
+ condition: 'WHERE count > 5',
+ },
+ },
+ grouping: { fields: ['host.name'] },
+ recovery_policy: { type: 'no_breach' },
+ state_transition: { pending_count: 3, pending_timeframe: '5m' },
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderClonePage();
+
+ const initialValues = capturedStandaloneProps.initialValues as Record;
+ expect(initialValues).toBeDefined();
+
+ const metadata = initialValues.metadata as Record;
+ expect(metadata.name).toBe('My Alert Rule (clone)');
+ });
+
+ it('preserves all source rule configurations in initialValues', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: {
+ id: 'rule-1',
+ kind: 'alert',
+ enabled: true,
+ metadata: { name: 'My Alert Rule', labels: ['prod'], owner: 'team-a' },
+ time_field: '@timestamp',
+ schedule: { every: '10m', lookback: '2m' },
+ evaluation: {
+ query: {
+ base: 'FROM logs-* | STATS count() BY host.name',
+ condition: 'WHERE count > 5',
+ },
+ },
+ grouping: { fields: ['host.name'] },
+ recovery_policy: { type: 'no_breach' },
+ state_transition: { pending_count: 3, pending_timeframe: '5m' },
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderClonePage();
+
+ const initialValues = capturedStandaloneProps.initialValues as Record;
+
+ // Metadata (labels, owner preserved)
+ const metadata = initialValues.metadata as Record;
+ expect(metadata.labels).toEqual(['prod']);
+ expect(metadata.owner).toBe('team-a');
+
+ // Schedule
+ const schedule = initialValues.schedule as Record;
+ expect(schedule.every).toBe('10m');
+ expect(schedule.lookback).toBe('2m');
+
+ // Evaluation
+ const evaluation = initialValues.evaluation as { query: Record };
+ expect(evaluation.query.base).toBe('FROM logs-* | STATS count() BY host.name');
+ expect(evaluation.query.condition).toBe('WHERE count > 5');
+
+ // Grouping
+ const grouping = initialValues.grouping as { fields: string[] };
+ expect(grouping.fields).toEqual(['host.name']);
+
+ // Recovery policy
+ const recoveryPolicy = initialValues.recoveryPolicy as Record;
+ expect(recoveryPolicy.type).toBe('no_breach');
+
+ // State transition
+ const stateTransition = initialValues.stateTransition as Record;
+ expect(stateTransition.pendingCount).toBe(3);
+ expect(stateTransition.pendingTimeframe).toBe('5m');
+ });
+
+ it('passes the base query from the source rule as the query prop', () => {
+ mockUseFetchRule.mockReturnValue({
+ data: {
+ id: 'rule-1',
+ kind: 'alert',
+ enabled: true,
+ metadata: { name: 'Test' },
+ time_field: '@timestamp',
+ schedule: { every: '5m' },
+ evaluation: { query: { base: 'FROM metrics-* | LIMIT 10' } },
+ },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderClonePage();
+
+ expect(capturedStandaloneProps.query).toBe('FROM metrics-* | LIMIT 10');
+ });
+ });
});
diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx
index 28fc4b6ab24db..9c51a2b1ee7eb 100644
--- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx
+++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx
@@ -12,42 +12,65 @@ import { PluginStart } from '@kbn/core-di';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
-import { useParams } from 'react-router-dom';
+import { useParams, useLocation } from 'react-router-dom';
import { useQueryClient } from '@kbn/react-query';
import { StandaloneRuleForm, mapRuleResponseToFormValues } from '@kbn/alerting-v2-rule-form';
import type { FormValues } from '@kbn/alerting-v2-rule-form';
+import { i18n } from '@kbn/i18n';
import { useFetchRule } from '../../hooks/use_fetch_rule';
import { ruleKeys } from '../../hooks/query_key_factory';
import { paths } from '../../constants';
const DEFAULT_QUERY = 'FROM logs-*\n| LIMIT 1';
+const CLONE_NAME_SUFFIX = i18n.translate('xpack.alertingV2.ruleFormPage.cloneNameSuffix', {
+ defaultMessage: ' (clone)',
+});
+
export const RuleFormPage = () => {
const { id: ruleId } = useParams<{ id?: string }>();
- const isEditing = Boolean(ruleId);
+ const { search } = useLocation();
+ const cloneFromId = new URLSearchParams(search).get('cloneFrom');
- if (isEditing && ruleId) {
- return ;
+ if (ruleId) {
+ return ;
+ }
+
+ if (cloneFromId) {
+ return ;
}
return ;
};
-const EditRuleFormPageContent = ({ ruleId }: { ruleId: string }) => {
+interface FetchedRuleFormPageProps {
+ ruleId: string;
+ mode: 'edit' | 'clone';
+}
+
+const FetchedRuleFormPage = ({ ruleId, mode }: FetchedRuleFormPageProps) => {
+ const isClone = mode === 'clone';
const { data: rule, isLoading, isError, error } = useFetchRule(ruleId);
if (isLoading) {
return ;
}
- if (isError) {
+ if (isError || (!rule && !isLoading)) {
return (
+ isClone ? (
+
+ ) : (
+
+ )
}
color="danger"
iconType="error"
@@ -58,16 +81,19 @@ const EditRuleFormPageContent = ({ ruleId }: { ruleId: string }) => {
);
}
- if (!rule) {
- return null;
- }
-
const initialQuery = rule.evaluation?.query?.base ?? DEFAULT_QUERY;
const initialValues = mapRuleResponseToFormValues(rule);
+ if (isClone && initialValues.metadata) {
+ initialValues.metadata = {
+ ...initialValues.metadata,
+ name: `${initialValues.metadata.name}${CLONE_NAME_SUFFIX}`,
+ };
+ }
+
return (
@@ -103,7 +129,6 @@ const RuleFormPageContent = ({ ruleId, initialQuery, initialValues }: RuleFormPa
);
const onSuccess = useCallback(() => {
- // Invalidate cached rule data so the next edit fetches fresh data
queryClient.invalidateQueries(ruleKeys.lists());
if (ruleId) {
queryClient.invalidateQueries(ruleKeys.detail(ruleId));
@@ -115,23 +140,21 @@ const RuleFormPageContent = ({ ruleId, initialQuery, initialValues }: RuleFormPa
navigateToUrl(basePath.prepend(paths.ruleList));
};
+ const pageTitle = isEditing ? (
+
+ ) : (
+
+ );
+
+ const submitLabel = isEditing ? (
+
+ ) : (
+
+ );
+
return (
<>
-
- ) : (
-
- )
- }
- />
+
- ) : (
-
- )
- }
+ submitLabel={submitLabel}
/>
>
);
diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx
index 525b046520b43..abc0a56f4bfec 100644
--- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx
+++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx
@@ -38,6 +38,12 @@ jest.mock('../../hooks/use_delete_rule', () => ({
useDeleteRule: () => mockUseDeleteRule(),
}));
+const mockToggleEnabledMutate = jest.fn();
+const mockUseToggleRuleEnabled = jest.fn();
+jest.mock('../../hooks/use_toggle_rule_enabled', () => ({
+ useToggleRuleEnabled: () => mockUseToggleRuleEnabled(),
+}));
+
const mockRules = [
{
id: 'rule-1',
@@ -81,6 +87,10 @@ describe('RulesListPage', () => {
mutate: mockDeleteMutate,
isLoading: false,
});
+ mockUseToggleRuleEnabled.mockReturnValue({
+ mutate: mockToggleEnabledMutate,
+ isLoading: false,
+ });
});
it('renders loading state', () => {
@@ -235,4 +245,90 @@ describe('RulesListPage', () => {
})
);
});
+
+ it('renders the Status column with Enabled and Disabled badges', () => {
+ mockUseFetchRules.mockReturnValue({
+ data: { items: mockRules, total: 2, page: 1, perPage: 20 },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderPage();
+
+ expect(screen.getByText('Enabled')).toBeInTheDocument();
+ expect(screen.getByText('Disabled')).toBeInTheDocument();
+ });
+
+ it('shows "Disable" action for enabled rules and "Enable" for disabled rules', async () => {
+ mockUseFetchRules.mockReturnValue({
+ data: { items: mockRules, total: 2, page: 1, perPage: 20 },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderPage();
+
+ // Open the context menu for the enabled rule (rule-1)
+ fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('toggleEnabledRule-rule-1')).toHaveTextContent('Disable');
+ });
+ });
+
+ it('calls toggleEnabledMutation when toggle action is clicked', async () => {
+ mockUseFetchRules.mockReturnValue({
+ data: { items: mockRules, total: 2, page: 1, perPage: 20 },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderPage();
+
+ // Open the context menu for the enabled rule (rule-1)
+ fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1'));
+
+ // Click the toggle action — should disable the enabled rule
+ fireEvent.click(screen.getByTestId('toggleEnabledRule-rule-1'));
+
+ expect(mockToggleEnabledMutate).toHaveBeenCalledWith({ id: 'rule-1', enabled: false });
+ });
+
+ it('shows "Clone" action in the context menu', async () => {
+ mockUseFetchRules.mockReturnValue({
+ data: { items: mockRules, total: 2, page: 1, perPage: 20 },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderPage();
+
+ fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('cloneRule-rule-1')).toHaveTextContent('Clone');
+ });
+ });
+
+ it('navigates to the create page with cloneFrom query param when clone is clicked', async () => {
+ mockUseFetchRules.mockReturnValue({
+ data: { items: mockRules, total: 2, page: 1, perPage: 20 },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+
+ renderPage();
+
+ fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1'));
+ fireEvent.click(screen.getByTestId('cloneRule-rule-1'));
+
+ expect(mockNavigateToUrl).toHaveBeenCalledWith(
+ '/app/management/insightsAndAlerting/alerting_v2/create?cloneFrom=rule-1'
+ );
+ });
});
diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx
index f4e5ac4cd20f4..8b42393c19019 100644
--- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx
+++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx
@@ -32,6 +32,7 @@ import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import type { RuleApiResponse } from '../../services/rules_api';
import { useFetchRules } from '../../hooks/use_fetch_rules';
import { useDeleteRule } from '../../hooks/use_delete_rule';
+import { useToggleRuleEnabled } from '../../hooks/use_toggle_rule_enabled';
import { DeleteConfirmationModal } from '../../components/rule/delete_confirmation_modal';
import { paths } from '../../constants';
@@ -40,10 +41,18 @@ const DEFAULT_PER_PAGE = 20;
interface RuleActionsMenuProps {
rule: RuleApiResponse;
onEdit: (rule: RuleApiResponse) => void;
+ onClone: (rule: RuleApiResponse) => void;
onDelete: (rule: RuleApiResponse) => void;
+ onToggleEnabled: (rule: RuleApiResponse) => void;
}
-const RuleActionsMenu = ({ rule, onEdit, onDelete }: RuleActionsMenuProps) => {
+const RuleActionsMenu = ({
+ rule,
+ onEdit,
+ onClone,
+ onDelete,
+ onToggleEnabled,
+}: RuleActionsMenuProps) => {
const [isOpen, setIsOpen] = useState(false);
const menuItems = [
@@ -58,6 +67,34 @@ const RuleActionsMenu = ({ rule, onEdit, onDelete }: RuleActionsMenuProps) => {
>
{i18n.translate('xpack.alertingV2.rulesList.action.edit', { defaultMessage: 'Edit' })}
,
+ }
+ onClick={() => {
+ setIsOpen(false);
+ onClone(rule);
+ }}
+ data-test-subj={`cloneRule-${rule.id}`}
+ >
+ {i18n.translate('xpack.alertingV2.rulesList.action.clone', { defaultMessage: 'Clone' })}
+ ,
+ }
+ onClick={() => {
+ setIsOpen(false);
+ onToggleEnabled(rule);
+ }}
+ data-test-subj={`toggleEnabledRule-${rule.id}`}
+ >
+ {rule.enabled
+ ? i18n.translate('xpack.alertingV2.rulesList.action.disable', {
+ defaultMessage: 'Disable',
+ })
+ : i18n.translate('xpack.alertingV2.rulesList.action.enable', {
+ defaultMessage: 'Enable',
+ })}
+ ,
}
@@ -106,6 +143,7 @@ export const RulesListPage = () => {
const { data, isLoading, isError, error } = useFetchRules({ page, perPage });
const deleteRuleMutation = useDeleteRule();
+ const toggleEnabledMutation = useToggleRuleEnabled();
const onTableChange = ({ page: tablePage }: CriteriaWithPagination) => {
setPage(tablePage.index + 1);
@@ -200,6 +238,27 @@ export const RulesListPage = () => {
),
},
+ {
+ field: 'enabled',
+ name: (
+
+ ),
+ width: '10%',
+ render: (enabled: boolean) =>
+ enabled ? (
+
+ {i18n.translate('xpack.alertingV2.rulesList.statusEnabled', {
+ defaultMessage: 'Enabled',
+ })}
+
+ ) : (
+
+ {i18n.translate('xpack.alertingV2.rulesList.statusDisabled', {
+ defaultMessage: 'Disabled',
+ })}
+
+ ),
+ },
{
name: (
@@ -210,7 +269,13 @@ export const RulesListPage = () => {
navigateToUrl(basePath.prepend(paths.ruleEdit(r.id)))}
+ onClone={(r) =>
+ navigateToUrl(
+ basePath.prepend(`${paths.ruleCreate}?cloneFrom=${encodeURIComponent(r.id)}`)
+ )
+ }
onDelete={(r) => setRuleToDelete(r)}
+ onToggleEnabled={(r) => toggleEnabledMutation.mutate({ id: r.id, enabled: !r.enabled })}
/>
),
},
diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts
index 5171c0b8c3e04..eb3cc1a83c0b3 100644
--- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts
+++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts
@@ -100,8 +100,8 @@ export function buildUpdateRuleAttributes(
updateData.notification_policies,
existingAttrs.notification_policies
),
+ enabled: updateData.enabled ?? existingAttrs.enabled,
// Server-managed fields — preserved as-is except timestamps and user.
- enabled: existingAttrs.enabled,
createdBy: existingAttrs.createdBy,
createdAt: existingAttrs.createdAt,
...serverFields,