[SIEM] [Cases] Case closed and add user email (#60463)

This commit is contained in:
Steph Milovic 2020-03-19 10:27:41 -06:00 committed by GitHub
parent fe4c164681
commit d5ed93ee63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 691 additions and 266 deletions

View file

@ -18,6 +18,8 @@ export interface Comment {
export interface Case {
id: string;
closedAt: string | null;
closedBy: ElasticUser | null;
comments: Comment[];
commentIds: string[];
createdAt: string;
@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus {
export enum SortFieldCase {
createdAt = 'createdAt',
updatedAt = 'updatedAt',
closedAt = 'closedAt',
}
export interface ElasticUser {
readonly username: string;
readonly email?: string | null;
readonly fullName?: string | null;
readonly username: string;
}
export interface FetchCasesProps {

View file

@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
};
const initialData: Case = {
id: '',
closedAt: null,
closedBy: null,
createdAt: '',
comments: [],
commentIds: [],

View file

@ -5,7 +5,7 @@
*/
import { useReducer, useCallback } from 'react';
import { cloneDeep } from 'lodash/fp';
import { CaseRequest } from '../../../../../../plugins/case/common/api';
import { errorToToaster, useStateToaster } from '../../components/toasters';
@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
...state,
isLoading: false,
isError: false,
caseData: action.payload,
caseData: cloneDeep(action.payload),
updateKey: null,
};
case 'FETCH_FAILURE':

View file

@ -13,6 +13,8 @@ export const useGetCasesMockState: UseGetCasesState = {
countOpenCases: 0,
cases: [
{
closedAt: null,
closedBy: null,
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { username: 'elastic' },
@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
closedAt: null,
closedBy: null,
id: '362a5c10-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:13.328Z',
createdBy: { username: 'elastic' },
@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
closedAt: null,
closedBy: null,
id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:11.328Z',
createdBy: { username: 'elastic' },
@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
closedAt: '2020-02-13T19:44:13.328Z',
closedBy: { username: 'elastic' },
id: '31890e90-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:05.563Z',
createdBy: { username: 'elastic' },
@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = {
status: 'closed',
tags: ['phishing'],
title: 'Uh oh',
updatedAt: null,
updatedBy: null,
updatedAt: '2020-02-13T19:44:13.328Z',
updatedBy: { username: 'elastic' },
version: 'WzQ3LDFd',
},
{
closedAt: null,
closedBy: null,
id: '2f5b3210-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:01.901Z',
createdBy: { username: 'elastic' },

View file

@ -36,7 +36,8 @@ const Spacer = styled.span`
const renderStringField = (field: string, dataTestSubj: string) =>
field != null ? <span data-test-subj={dataTestSubj}>{field}</span> : getEmptyTagValue();
export const getCasesColumns = (
actions: Array<DefaultItemIconButtonAction<Case>>
actions: Array<DefaultItemIconButtonAction<Case>>,
filterStatus: string
): CasesColumns[] => [
{
name: i18n.NAME,
@ -113,22 +114,39 @@ export const getCasesColumns = (
render: (comments: Case['commentIds']) =>
renderStringField(`${comments.length}`, `case-table-column-commentCount`),
},
{
field: 'createdAt',
name: i18n.OPENED_ON,
sortable: true,
render: (createdAt: Case['createdAt']) => {
if (createdAt != null) {
return (
<FormattedRelativePreferenceDate
value={createdAt}
data-test-subj={`case-table-column-createdAt`}
/>
);
filterStatus === 'open'
? {
field: 'createdAt',
name: i18n.OPENED_ON,
sortable: true,
render: (createdAt: Case['createdAt']) => {
if (createdAt != null) {
return (
<FormattedRelativePreferenceDate
value={createdAt}
data-test-subj={`case-table-column-createdAt`}
/>
);
}
return getEmptyTagValue();
},
}
return getEmptyTagValue();
},
},
: {
field: 'closedAt',
name: i18n.CLOSED_ON,
sortable: true,
render: (closedAt: Case['closedAt']) => {
if (closedAt != null) {
return (
<FormattedRelativePreferenceDate
value={closedAt}
data-test-subj={`case-table-column-closedAt`}
/>
);
}
return getEmptyTagValue();
},
},
{
name: 'Actions',
actions,

View file

@ -71,8 +71,8 @@ const ProgressLoader = styled(EuiProgress)`
const getSortField = (field: string): SortFieldCase => {
if (field === SortFieldCase.createdAt) {
return SortFieldCase.createdAt;
} else if (field === SortFieldCase.updatedAt) {
return SortFieldCase.updatedAt;
} else if (field === SortFieldCase.closedAt) {
return SortFieldCase.closedAt;
}
return SortFieldCase.createdAt;
};
@ -206,17 +206,25 @@ export const AllCases = React.memo(() => {
}
setQueryParams(newQueryParams);
},
[setQueryParams, queryParams]
[queryParams]
);
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial<FilterOptions>) => {
if (newFilterOptions.status && newFilterOptions.status === 'closed') {
setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt });
} else if (newFilterOptions.status && newFilterOptions.status === 'open') {
setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt });
}
setFilters({ ...filterOptions, ...newFilterOptions });
},
[filterOptions, setFilters]
[filterOptions, queryParams]
);
const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]);
const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [
actions,
filterOptions.status,
]);
const memoizedPagination = useMemo(
() => ({
pageIndex: queryParams.page - 1,

View file

@ -60,9 +60,3 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', {
export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', {
defaultMessage: 'Delete',
});
export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', {
defaultMessage: 'Reopen case',
});
export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', {
defaultMessage: 'Close case',
});

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components';
import {
EuiBadge,
EuiButtonToggle,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import * as i18n from '../case_view/translations';
import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date';
import { CaseViewActions } from '../case_view/actions';
const MyDescriptionList = styled(EuiDescriptionList)`
${({ theme }) => css`
& {
padding-right: ${theme.eui.euiSizeL};
border-right: ${theme.eui.euiBorderThin};
}
`}
`;
interface CaseStatusProps {
'data-test-subj': string;
badgeColor: string;
buttonLabel: string;
caseId: string;
caseTitle: string;
icon: string;
isLoading: boolean;
isSelected: boolean;
status: string;
title: string;
toggleStatusCase: (status: string) => void;
value: string | null;
}
const CaseStatusComp: React.FC<CaseStatusProps> = ({
'data-test-subj': dataTestSubj,
badgeColor,
buttonLabel,
caseId,
caseTitle,
icon,
isLoading,
isSelected,
status,
title,
toggleStatusCase,
value,
}) => {
const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [
toggleStatusCase,
]);
return (
<EuiFlexGroup gutterSize="l" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<MyDescriptionList compressed>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiBadge color={badgeColor} data-test-subj="case-view-status">
{status}
</EuiBadge>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>{title}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedRelativePreferenceDate data-test-subj={dataTestSubj} value={value} />
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</MyDescriptionList>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="l" alignItems="center">
<EuiFlexItem>
<EuiButtonToggle
data-test-subj="toggle-case-status"
iconType={icon}
isLoading={isLoading}
isSelected={isSelected}
label={buttonLabel}
onChange={onChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CaseViewActions caseId={caseId} caseTitle={caseTitle} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const CaseStatus = React.memo(CaseStatusComp);

View file

@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types';
export const caseProps: CaseProps = {
caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
initialData: {
closedAt: null,
closedBy: null,
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'],
comments: [
@ -20,6 +22,7 @@ export const caseProps: CaseProps = {
createdBy: {
fullName: 'Steph Milovic',
username: 'smilovic',
email: 'notmyrealemailfool@elastic.co',
},
updatedAt: '2020-02-20T23:06:33.798Z',
updatedBy: {
@ -29,7 +32,7 @@ export const caseProps: CaseProps = {
},
],
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { fullName: null, username: 'elastic' },
createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' },
description: 'Security banana Issue',
status: 'open',
tags: ['defacement'],
@ -41,35 +44,22 @@ export const caseProps: CaseProps = {
version: 'WzQ3LDFd',
},
};
export const caseClosedProps: CaseProps = {
...caseProps,
initialData: {
...caseProps.initialData,
closedAt: '2020-02-20T23:06:33.798Z',
closedBy: {
username: 'elastic',
},
status: 'closed',
},
};
export const data: Case = {
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'],
comments: [
{
comment: 'Solve this fast!',
id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
createdAt: '2020-02-20T23:06:33.798Z',
createdBy: {
fullName: 'Steph Milovic',
username: 'smilovic',
},
updatedAt: '2020-02-20T23:06:33.798Z',
updatedBy: {
username: 'elastic',
},
version: 'WzQ3LDFd',
},
],
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { username: 'elastic', fullName: null },
description: 'Security banana Issue',
status: 'open',
tags: ['defacement'],
title: 'Another horrible breach!!',
updatedAt: '2020-02-19T15:02:57.995Z',
updatedBy: {
username: 'elastic',
},
version: 'WzQ3LDFd',
...caseProps.initialData,
};
export const dataClosed: Case = {
...caseClosedProps.initialData,
};

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { CaseViewActions } from './actions';
import { TestProviders } from '../../../../mock';
import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
jest.mock('../../../../containers/case/use_delete_cases');
const useDeleteCasesMock = useDeleteCases as jest.Mock;
describe('CaseView actions', () => {
const caseTitle = 'Cool title';
const caseId = 'cool-id';
const handleOnDeleteConfirm = jest.fn();
const handleToggleModal = jest.fn();
const dispatchResetIsDeleted = jest.fn();
const defaultDeleteState = {
dispatchResetIsDeleted,
handleToggleModal,
handleOnDeleteConfirm,
isLoading: false,
isError: false,
isDeleted: false,
isDisplayConfirmDeleteModal: false,
};
beforeEach(() => {
jest.resetAllMocks();
useDeleteCasesMock.mockImplementation(() => defaultDeleteState);
});
it('clicking trash toggles modal', () => {
const wrapper = mount(
<TestProviders>
<CaseViewActions caseTitle={caseTitle} caseId={caseId} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
wrapper
.find('button[data-test-subj="property-actions-ellipses"]')
.first()
.simulate('click');
wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click');
expect(handleToggleModal).toHaveBeenCalled();
});
it('toggle delete modal and confirm', () => {
useDeleteCasesMock.mockImplementation(() => ({
...defaultDeleteState,
isDisplayConfirmDeleteModal: true,
}));
const wrapper = mount(
<TestProviders>
<CaseViewActions caseTitle={caseTitle} caseId={caseId} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]);
});
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { Redirect } from 'react-router-dom';
import * as i18n from './translations';
import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { SiemPageName } from '../../../home/types';
import { PropertyActions } from '../property_actions';
interface CaseViewActions {
caseId: string;
caseTitle: string;
}
const CaseViewActionsComponent: React.FC<CaseViewActions> = ({ caseId, caseTitle }) => {
// Delete case
const {
handleToggleModal,
handleOnDeleteConfirm,
isDeleted,
isDisplayConfirmDeleteModal,
} = useDeleteCases();
const confirmDeleteModal = useMemo(
() => (
<ConfirmDeleteCaseModal
caseTitle={caseTitle}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [caseId])}
/>
),
[isDisplayConfirmDeleteModal]
);
// TO DO refactor each of these const's into their own components
const propertyActions = useMemo(
() => [
{
iconType: 'trash',
label: i18n.DELETE_CASE,
onClick: handleToggleModal,
},
{
iconType: 'popout',
label: 'View ServiceNow incident',
onClick: () => null,
},
{
iconType: 'importAction',
label: 'Update ServiceNow incident',
onClick: () => null,
},
],
[handleToggleModal]
);
if (isDeleted) {
return <Redirect to={`/${SiemPageName.case}`} />;
}
return (
<>
<PropertyActions propertyActions={propertyActions} />
{confirmDeleteModal}
</>
);
};
export const CaseViewActions = React.memo(CaseViewActionsComponent);

View file

@ -7,15 +7,13 @@
import React from 'react';
import { mount } from 'enzyme';
import { CaseComponent } from './';
import * as updateHook from '../../../../containers/case/use_update_case';
import * as deleteHook from '../../../../containers/case/use_delete_cases';
import { caseProps, data } from './__mock__';
import { caseProps, caseClosedProps, data, dataClosed } from './__mock__';
import { TestProviders } from '../../../../mock';
import { useUpdateCase } from '../../../../containers/case/use_update_case';
jest.mock('../../../../containers/case/use_update_case');
const useUpdateCaseMock = useUpdateCase as jest.Mock;
describe('CaseView ', () => {
const handleOnDeleteConfirm = jest.fn();
const handleToggleModal = jest.fn();
const dispatchResetIsDeleted = jest.fn();
const updateCaseProperty = jest.fn();
/* eslint-disable no-console */
// Silence until enzyme fixed to use ReactTestUtils.act()
@ -28,15 +26,17 @@ describe('CaseView ', () => {
});
/* eslint-enable no-console */
const defaultUpdateCaseState = {
caseData: data,
isLoading: false,
isError: false,
updateKey: null,
updateCaseProperty,
};
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(updateHook, 'useUpdateCase').mockReturnValue({
caseData: data,
isLoading: false,
isError: false,
updateKey: null,
updateCaseProperty,
});
useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState);
});
it('should render CaseComponent', () => {
@ -69,6 +69,7 @@ describe('CaseView ', () => {
.first()
.text()
).toEqual(data.createdBy.username);
expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false);
expect(
wrapper
.find(`[data-test-subj="case-view-createdAt"]`)
@ -82,6 +83,30 @@ describe('CaseView ', () => {
.prop('raw')
).toEqual(data.description);
});
it('should show closed indicators in header when case is closed', () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
caseData: dataClosed,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseClosedProps} />
</TestProviders>
);
expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false);
expect(
wrapper
.find(`[data-test-subj="case-view-closedAt"]`)
.first()
.prop('value')
).toEqual(dataClosed.closedAt);
expect(
wrapper
.find(`[data-test-subj="case-view-status"]`)
.first()
.text()
).toEqual(dataClosed.status);
});
it('should dispatch update state when button is toggled', () => {
const wrapper = mount(
@ -92,7 +117,7 @@ describe('CaseView ', () => {
wrapper
.find('input[data-test-subj="toggle-case-status"]')
.simulate('change', { target: { value: false } });
.simulate('change', { target: { checked: true } });
expect(updateCaseProperty).toBeCalledWith({
updateKey: 'status',
@ -133,46 +158,4 @@ describe('CaseView ', () => {
.prop('source')
).toEqual(data.comments[0].comment);
});
it('toggle delete modal and cancel', () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
wrapper
.find(
'[data-test-subj="case-view-actions"] button[data-test-subj="property-actions-ellipses"]'
)
.first()
.simulate('click');
wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click');
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalCancelButton"]').simulate('click');
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
});
it('toggle delete modal and confirm', () => {
jest.spyOn(deleteHook, 'useDeleteCases').mockReturnValue({
dispatchResetIsDeleted,
handleToggleModal,
handleOnDeleteConfirm,
isLoading: false,
isError: false,
isDeleted: false,
isDisplayConfirmDeleteModal: true,
});
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseProps.caseId]);
});
});

View file

@ -5,26 +5,14 @@
*/
import React, { useCallback, useMemo } from 'react';
import {
EuiBadge,
EuiButtonToggle,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import styled, { css } from 'styled-components';
import { Redirect } from 'react-router-dom';
import styled from 'styled-components';
import * as i18n from './translations';
import { Case } from '../../../../containers/case/types';
import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date';
import { getCaseUrl } from '../../../../components/link_to';
import { HeaderPage } from '../../../../components/header_page';
import { EditableTitle } from '../../../../components/header_page/editable_title';
import { PropertyActions } from '../property_actions';
import { TagList } from '../tag_list';
import { useGetCase } from '../../../../containers/case/use_get_case';
import { UserActionTree } from '../user_action_tree';
@ -33,23 +21,13 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case';
import { WrapperPage } from '../../../../components/wrapper_page';
import { getTypedPayload } from '../../../../containers/case/utils';
import { WhitePageWrapper } from '../wrappers';
import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
import { SiemPageName } from '../../../home/types';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { useBasePath } from '../../../../lib/kibana';
import { CaseStatus } from '../case_status';
interface Props {
caseId: string;
}
const MyDescriptionList = styled(EuiDescriptionList)`
${({ theme }) => css`
& {
padding-right: ${theme.eui.euiSizeL};
border-right: ${theme.eui.euiBorderThin};
}
`}
`;
const MyWrapper = styled(WrapperPage)`
padding-bottom: 0;
`;
@ -64,6 +42,8 @@ export interface CaseProps {
}
export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => {
const basePath = window.location.origin + useBasePath();
const caseLink = `${basePath}/app/siem#/case/${caseId}`;
const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData);
// Update Fields
@ -107,58 +87,44 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) =>
return null;
}
},
[updateCaseProperty, caseData.status]
[caseData.status]
);
const toggleStatusCase = useCallback(
e => onUpdateField('status', e.target.checked ? 'open' : 'closed'),
[onUpdateField]
);
const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]);
const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]);
const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]);
const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]);
// Delete case
const {
handleToggleModal,
handleOnDeleteConfirm,
isDeleted,
isDisplayConfirmDeleteModal,
} = useDeleteCases();
const confirmDeleteModal = useMemo(
() => (
<ConfirmDeleteCaseModal
caseTitle={caseData.title}
isModalVisible={isDisplayConfirmDeleteModal}
isPlural={false}
onCancel={handleToggleModal}
onConfirm={handleOnDeleteConfirm.bind(null, [caseId])}
/>
),
[isDisplayConfirmDeleteModal]
const caseStatusData = useMemo(
() =>
caseData.status === 'open'
? {
'data-test-subj': 'case-view-createdAt',
value: caseData.createdAt,
title: i18n.CASE_OPENED,
buttonLabel: i18n.CLOSE_CASE,
status: caseData.status,
icon: 'checkInCircleFilled',
badgeColor: 'secondary',
isSelected: false,
}
: {
'data-test-subj': 'case-view-closedAt',
value: caseData.closedAt,
title: i18n.CASE_CLOSED,
buttonLabel: i18n.REOPEN_CASE,
status: caseData.status,
icon: 'magnet',
badgeColor: 'danger',
isSelected: true,
},
[caseData.closedAt, caseData.createdAt, caseData.status]
);
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(caseLink),
}),
[caseData.title]
);
// TO DO refactor each of these const's into their own components
const propertyActions = [
{
iconType: 'trash',
label: 'Delete case',
onClick: handleToggleModal,
},
{
iconType: 'popout',
label: 'View ServiceNow incident',
onClick: () => null,
},
{
iconType: 'importAction',
label: 'Update ServiceNow incident',
onClick: () => null,
},
];
if (isDeleted) {
return <Redirect to={`/${SiemPageName.case}`} />;
}
return (
<>
<MyWrapper>
@ -177,51 +143,13 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) =>
}
title={caseData.title}
>
<EuiFlexGroup gutterSize="l" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<MyDescriptionList compressed>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiBadge
color={caseData.status === 'open' ? 'secondary' : 'danger'}
data-test-subj="case-view-status"
>
{caseData.status}
</EuiBadge>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>{i18n.CASE_OPENED}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedRelativePreferenceDate
data-test-subj="case-view-createdAt"
value={caseData.createdAt}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</MyDescriptionList>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="l" alignItems="center">
<EuiFlexItem>
<EuiButtonToggle
data-test-subj="toggle-case-status"
iconType={caseData.status === 'open' ? 'checkInCircleFilled' : 'magnet'}
isLoading={isLoading && updateKey === 'status'}
isSelected={caseData.status === 'open'}
label={caseData.status === 'open' ? 'Close case' : 'Reopen case'}
onChange={toggleStatusCase}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
<PropertyActions propertyActions={propertyActions} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<CaseStatus
caseId={caseData.id}
caseTitle={caseData.title}
isLoading={isLoading && updateKey === 'status'}
toggleStatusCase={toggleStatusCase}
{...caseStatusData}
/>
</HeaderPage>
</MyWrapper>
<WhitePageWrapper>
@ -237,6 +165,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) =>
<EuiFlexItem grow={2}>
<UserList
data-test-subj="case-view-user-list"
email={emailContent}
headline={i18n.REPORTER}
users={[caseData.createdBy]}
/>
@ -250,7 +179,6 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) =>
</EuiFlexGroup>
</MyWrapper>
</WhitePageWrapper>
{confirmDeleteModal}
</>
);
});
@ -273,4 +201,5 @@ export const CaseView = React.memo(({ caseId }: Props) => {
return <CaseComponent caseId={caseId} initialData={data} />;
});
CaseComponent.displayName = 'CaseComponent';
CaseView.displayName = 'CaseView';

View file

@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', {
export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', {
defaultMessage: 'Case opened',
});
export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', {
defaultMessage: 'Case closed',
});
export const EMAIL_SUBJECT = (caseTitle: string) =>
i18n.translate('xpack.siem.case.caseView.emailSubject', {
values: { caseTitle },
defaultMessage: 'SIEM Case - {caseTitle}',
});
export const EMAIL_BODY = (caseUrl: string) =>
i18n.translate('xpack.siem.case.caseView.emailBody', {
values: { caseUrl },
defaultMessage: 'Case reference: {caseUrl}',
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { UserList } from './';
import * as i18n from '../case_view/translations';
describe('UserList ', () => {
const title = 'Case Title';
const caseLink = 'http://reddit.com';
const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' };
const open = jest.fn();
beforeAll(() => {
window.open = open;
});
beforeEach(() => {
jest.resetAllMocks();
});
it('triggers mailto when email icon clicked', () => {
const wrapper = shallow(
<UserList
email={{
subject: i18n.EMAIL_SUBJECT(title),
body: i18n.EMAIL_BODY(caseLink),
}}
headline={i18n.REPORTER}
users={[user]}
/>
);
wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click');
expect(open).toBeCalledWith(
`mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`,
'_blank'
);
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useCallback } from 'react';
import {
EuiButtonIcon,
EuiText,
@ -17,6 +17,10 @@ import styled, { css } from 'styled-components';
import { ElasticUser } from '../../../../containers/case/types';
interface UserListProps {
email: {
subject: string;
body: string;
};
headline: string;
users: ElasticUser[];
}
@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)`
`}
`;
const renderUsers = (users: ElasticUser[]) => {
return users.map(({ fullName, username }, key) => (
const renderUsers = (
users: ElasticUser[],
handleSendEmail: (emailAddress: string | undefined | null) => void
) => {
return users.map(({ fullName, username, email }, key) => (
<MyFlexGroup key={key} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
onClick={() => {}} // TO DO
data-test-subj="user-list-email-button"
onClick={handleSendEmail.bind(null, email)} // TO DO
iconType="email"
aria-label="email"
/>
@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => {
));
};
export const UserList = React.memo(({ headline, users }: UserListProps) => {
export const UserList = React.memo(({ email, headline, users }: UserListProps) => {
const handleSendEmail = useCallback(
(emailAddress: string | undefined | null) => {
if (emailAddress && emailAddress != null) {
window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank');
}
},
[email.subject]
);
return (
<EuiText>
<h4>{headline}</h4>
<EuiHorizontalRule margin="xs" />
{renderUsers(users)}
{renderUsers(users, handleSendEmail)}
</EuiText>
);
});

View file

@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', {
defaultMessage: 'Opened on',
});
export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', {
defaultMessage: 'Closed on',
});
export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', {
defaultMessage: 'Reopen case',
});
export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', {
defaultMessage: 'Close case',
});
export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', {
defaultMessage: 'Reporter',
});

View file

@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([
CaseBasicRt,
rt.type({
comment_ids: rt.array(rt.string),
closed_at: rt.union([rt.string, rt.null]),
closed_by: rt.union([UserRT, rt.null]),
created_at: rt.string,
created_by: UserRT,
updated_at: rt.union([rt.string, rt.null]),

View file

@ -7,6 +7,7 @@
import * as rt from 'io-ts';
export const UserRT = rt.type({
email: rt.union([rt.undefined, rt.string]),
full_name: rt.union([rt.undefined, rt.string]),
username: rt.string,
});

View file

@ -13,7 +13,11 @@ function createAuthenticationMock({
authc.getCurrentUser.mockReturnValue(
currentUser !== undefined
? currentUser
: ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser)
: ({
email: 'd00d@awesome.com',
username: 'awesome',
full_name: 'Awesome D00d',
} as AuthenticatedUser)
);
return authc;
}

View file

@ -12,10 +12,13 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
type: 'cases',
id: 'mock-id-1',
attributes: {
closed_at: null,
closed_by: null,
comment_ids: ['mock-comment-1'],
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'This is a brand new case of a bad meanie defacing data',
@ -25,6 +28,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},
@ -36,10 +40,13 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
type: 'cases',
id: 'mock-id-2',
attributes: {
closed_at: null,
closed_by: null,
comment_ids: [],
created_at: '2019-11-25T22:32:00.900Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'Oh no, a bad meanie destroying data!',
@ -49,6 +56,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
updated_at: '2019-11-25T22:32:00.900Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},
@ -60,10 +68,13 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
type: 'cases',
id: 'mock-id-3',
attributes: {
closed_at: null,
closed_by: null,
comment_ids: [],
created_at: '2019-11-25T22:32:17.947Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'Oh no, a bad meanie going LOLBins all over the place!',
@ -73,6 +84,39 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
updated_at: '2019-11-25T22:32:17.947Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},
references: [],
updated_at: '2019-11-25T22:32:17.947Z',
version: 'WzUsMV0=',
},
{
type: 'cases',
id: 'mock-id-4',
attributes: {
closed_at: '2019-11-25T22:32:17.947Z',
closed_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
comment_ids: [],
created_at: '2019-11-25T22:32:17.947Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'Oh no, a bad meanie going LOLBins all over the place!',
title: 'Another bad one',
status: 'closed',
tags: ['LOLBins'],
updated_at: '2019-11-25T22:32:17.947Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},
@ -100,11 +144,13 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},
@ -126,11 +172,13 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
created_at: '2019-11-25T21:55:14.633Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
updated_at: '2019-11-25T21:55:14.633Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},
@ -153,11 +201,13 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
created_at: '2019-11-25T22:32:30.608Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
updated_at: '2019-11-25T22:32:30.608Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
},

View file

@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) {
}
const updatedBy = await caseService.getUser({ request, response });
const { full_name, username } = updatedBy;
const { email, full_name, username } = updatedBy;
const updatedComment = await caseService.patchComment({
client: context.core.savedObjects.client,
commentId: query.id,
updatedAttributes: {
comment: query.comment,
updated_at: new Date().toISOString(),
updated_by: { full_name, username },
updated_by: { email, full_name, username },
},
version: query.version,
});

View file

@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
}
const updatedBy = await caseService.getUser({ request, response });
const { full_name, username } = updatedBy;
const { email, full_name, username } = updatedBy;
const updateDate = new Date().toISOString();
const patch = await caseConfigureService.patch({
@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
updatedAttributes: {
...queryWithoutVersion,
updated_at: updateDate,
updated_by: { full_name, username },
updated_by: { email, full_name, username },
},
});

View file

@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
);
}
const updatedBy = await caseService.getUser({ request, response });
const { full_name, username } = updatedBy;
const { email, full_name, username } = updatedBy;
const creationDate = new Date().toISOString();
const post = await caseConfigureService.post({
@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
attributes: {
...query,
created_at: creationDate,
created_by: { full_name, username },
created_by: { email, full_name, username },
updated_at: null,
updated_by: null,
},

View file

@ -34,6 +34,6 @@ describe('GET all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases).toHaveLength(3);
expect(response.payload.cases).toHaveLength(4);
});
});

View file

@ -25,7 +25,7 @@ describe('PATCH cases', () => {
toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'),
}));
});
it(`Patch a case`, async () => {
it(`Close a case`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
@ -50,17 +50,61 @@ describe('PATCH cases', () => {
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
closed_at: '2019-11-25T21:54:48.952Z',
closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
comment_ids: ['mock-comment-1'],
comments: [],
created_at: '2019-11-25T21:54:48.952Z',
created_by: { full_name: 'elastic', username: 'elastic' },
created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
status: 'closed',
tags: ['defacement'],
title: 'Super Bad Security Issue',
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: { full_name: 'Awesome D00d', username: 'awesome' },
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
version: 'WzE3LDFd',
},
]);
});
it(`Open a case`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
body: {
cases: [
{
id: 'mock-id-4',
status: 'open',
version: 'WzUsMV0=',
},
],
},
});
const theContext = createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
closed_at: null,
closed_by: null,
comment_ids: [],
comments: [],
created_at: '2019-11-25T22:32:17.947Z',
created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
description: 'Oh no, a bad meanie going LOLBins all over the place!',
id: 'mock-id-4',
status: 'open',
tags: ['LOLBins'],
title: 'Another bad one',
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
version: 'WzE3LDFd',
},
]);

View file

@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) {
client: context.core.savedObjects.client,
caseIds: query.cases.map(q => q.id),
});
let nonExistingCases: CasePatchRequest[] = [];
const conflictedCases = query.cases.filter(q => {
const myCase = myCases.saved_objects.find(c => c.id === q.id);
if (myCase && myCase.error) {
nonExistingCases = [...nonExistingCases, q];
return false;
}
return myCase == null || myCase?.version !== q.version;
});
if (nonExistingCases.length > 0) {
throw Boom.notFound(
`These cases ${nonExistingCases
.map(c => c.id)
.join(', ')} do not exist. Please check you have the correct ids.`
);
}
if (conflictedCases.length > 0) {
throw Boom.conflict(
`These cases ${conflictedCases
@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) {
});
if (updateFilterCases.length > 0) {
const updatedBy = await caseService.getUser({ request, response });
const { full_name, username } = updatedBy;
const { email, full_name, username } = updatedBy;
const updatedDt = new Date().toISOString();
const updatedCases = await caseService.patchCases({
client: context.core.savedObjects.client,
cases: updateFilterCases.map(thisCase => {
const { id: caseId, version, ...updateCaseAttributes } = thisCase;
let closedInfo = {};
if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') {
closedInfo = {
closed_at: updatedDt,
closed_by: { email, full_name, username },
};
} else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') {
closedInfo = {
closed_at: null,
closed_by: null,
};
}
return {
caseId,
updatedAttributes: {
...updateCaseAttributes,
...closedInfo,
updated_at: updatedDt,
updated_by: { full_name, username },
updated_by: { email, full_name, username },
},
version,
};

View file

@ -14,7 +14,7 @@ export interface RouteDeps {
}
export enum SortFieldCase {
closedAt = 'closed_at',
createdAt = 'created_at',
status = 'status',
updatedAt = 'updated_at',
}

View file

@ -26,18 +26,22 @@ import { SortFieldCase } from './types';
export const transformNewCase = ({
createdDate,
newCase,
email,
full_name,
newCase,
username,
}: {
createdDate: string;
newCase: CaseRequest;
email?: string;
full_name?: string;
newCase: CaseRequest;
username: string;
}): CaseAttributes => ({
closed_at: newCase.status === 'closed' ? createdDate : null,
closed_by: newCase.status === 'closed' ? { email, full_name, username } : null,
comment_ids: [],
created_at: createdDate,
created_by: { full_name, username },
created_by: { email, full_name, username },
updated_at: null,
updated_by: null,
...newCase,
@ -46,18 +50,20 @@ export const transformNewCase = ({
interface NewCommentArgs {
comment: string;
createdDate: string;
email?: string;
full_name?: string;
username: string;
}
export const transformNewComment = ({
comment,
createdDate,
email,
full_name,
username,
}: NewCommentArgs): CommentAttributes => ({
comment,
created_at: createdDate,
created_by: { full_name, username },
created_by: { email, full_name, username },
updated_at: null,
updated_by: null,
});
@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => {
case 'createdAt':
case 'created_at':
return SortFieldCase.createdAt;
case 'updatedAt':
case 'updated_at':
return SortFieldCase.updatedAt;
case 'closedAt':
case 'closed_at':
return SortFieldCase.closedAt;
default:
return SortFieldCase.createdAt;
}

View file

@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = {
namespaceAgnostic: false,
mappings: {
properties: {
closed_at: {
type: 'date',
},
closed_by: {
properties: {
username: {
type: 'keyword',
},
full_name: {
type: 'keyword',
},
email: {
type: 'keyword',
},
},
},
comment_ids: {
type: 'keyword',
},
@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = {
full_name: {
type: 'keyword',
},
email: {
type: 'keyword',
},
},
},
description: {
@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = {
full_name: {
type: 'keyword',
},
email: {
type: 'keyword',
},
},
},
},

View file

@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
username: {
type: 'keyword',
},
email: {
type: 'keyword',
},
},
},
updated_at: {
@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
full_name: {
type: 'keyword',
},
email: {
type: 'keyword',
},
},
},
},