diff --git a/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx b/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx index 1d269dffeccf5..0c4497f7630c9 100644 --- a/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/filter_popover/index.tsx @@ -29,19 +29,15 @@ const ScrollableDiv = styled.div` overflow: auto; `; -export const toggleSelectedGroup = ( - group: string, - selectedGroups: string[], - setSelectedGroups: Dispatch> -): void => { +const toggleSelectedGroup = (group: string, selectedGroups: string[]): string[] => { const selectedGroupIndex = selectedGroups.indexOf(group); - const updatedSelectedGroups = [...selectedGroups]; if (selectedGroupIndex >= 0) { - updatedSelectedGroups.splice(selectedGroupIndex, 1); - } else { - updatedSelectedGroups.push(group); + return [ + ...selectedGroups.slice(0, selectedGroupIndex), + ...selectedGroups.slice(selectedGroupIndex + 1), + ]; } - return setSelectedGroups(updatedSelectedGroups); + return [...selectedGroups, group]; }; /** @@ -64,7 +60,7 @@ export const FilterPopoverComponent = ({ const setIsPopoverOpenCb = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); const toggleSelectedGroupCb = useCallback( - option => toggleSelectedGroup(option, selectedOptions, onSelectedOptionsChanged), + option => onSelectedOptionsChanged(toggleSelectedGroup(option, selectedOptions)), [selectedOptions, onSelectedOptionsChanged] ); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index ce98dd3573d30..284c8958f9649 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -7,23 +7,26 @@ import { CaseResponse, CasesResponse, + CasesFindResponse, CaseRequest, + CasesStatusResponse, CommentRequest, CommentResponse, + User, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; -import { AllCases, Case, Comment, FetchCasesProps, SortFieldCase } from './types'; +import { AllCases, Case, CasesStatus, Comment, FetchCasesProps, SortFieldCase } from './types'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel, decodeCaseResponse, decodeCasesResponse, + decodeCasesFindResponse, + decodeCasesStatusResponse, decodeCommentResponse, } from './utils'; -const CaseSavedObjectType = 'cases'; - export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'GET', @@ -34,6 +37,17 @@ export const getCase = async (caseId: string, includeComments: boolean = true): return convertToCamelCase(decodeCaseResponse(response)); }; +export const getCasesStatus = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/status`, + { + method: 'GET', + signal, + } + ); + return convertToCamelCase(decodeCasesStatusResponse(response)); +}; + export const getTags = async (): Promise => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/tags`, { method: 'GET', @@ -41,10 +55,19 @@ export const getTags = async (): Promise => { return response ?? []; }; +export const getReporters = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/reporters`, { + method: 'GET', + signal, + }); + return response ?? []; +}; + export const getCases = async ({ filterOptions = { search: '', - state: 'open', + reporters: [], + status: 'open', tags: [], }, queryParams = { @@ -54,23 +77,18 @@ export const getCases = async ({ sortOrder: 'desc', }, }: FetchCasesProps): Promise => { - const stateFilter = `${CaseSavedObjectType}.attributes.state: ${filterOptions.state}`; - const tags = [ - ...(filterOptions.tags?.reduce( - (acc, t) => [...acc, `${CaseSavedObjectType}.attributes.tags: ${t}`], - [stateFilter] - ) ?? [stateFilter]), - ]; const query = { - ...queryParams, - ...(tags.length > 0 ? { filter: tags.join(' AND ') } : {}), + reporters: filterOptions.reporters.map(r => r.username), + tags: filterOptions.tags, + ...(filterOptions.status !== '' ? { status: filterOptions.status } : {}), ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), + ...queryParams, }; - const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { method: 'GET', query, }); - return convertAllCasesToCamel(decodeCasesResponse(response)); + return convertAllCasesToCamel(decodeCasesFindResponse(response)); }; export const postCase = async (newCase: CaseRequest): Promise => { @@ -85,12 +103,12 @@ export const patchCase = async ( caseId: string, updatedCase: Partial, version: string -): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { +): Promise => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { method: 'PATCH', - body: JSON.stringify({ ...updatedCase, id: caseId, version }), + body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }), }); - return convertToCamelCase(decodeCaseResponse(response)); + return convertToCamelCase(decodeCasesResponse(response)); }; export const postComment = async (newComment: CommentRequest, caseId: string): Promise => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index c89993ec67179..74e9515a154de 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { User } from '../../../../../../plugins/case/common/api'; + export interface Comment { id: string; createdAt: string; @@ -20,7 +22,7 @@ export interface Case { createdAt: string; createdBy: ElasticUser; description: string; - state: string; + status: string; tags: string[]; title: string; updatedAt: string; @@ -36,11 +38,17 @@ export interface QueryParams { export interface FilterOptions { search: string; - state: string; + status: string; tags: string[]; + reporters: User[]; +} + +export interface CasesStatus { + countClosedCases: number | null; + countOpenCases: number | null; } -export interface AllCases { +export interface AllCases extends CasesStatus { cases: Case[]; page: number; perPage: number; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index 6020969ed6375..3436aa8908117 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -56,7 +56,7 @@ const initialData: Case = { username: '', }, description: '', - state: '', + status: '', tags: [], title: '', updatedAt: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 1c7c30ae9da18..6c4a6ac4fe58a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -13,7 +13,6 @@ import { UpdateByKey } from './use_update_case'; import { getCases, patchCase } from './api'; export interface UseGetCasesState { - caseCount: CaseCount; data: AllCases; filterOptions: FilterOptions; isError: boolean; @@ -22,20 +21,18 @@ export interface UseGetCasesState { selectedCases: Case[]; } -export interface CaseCount { - open: number; - closed: number; -} - export interface UpdateCase extends UpdateByKey { caseId: string; version: string; + refetchCasesStatus: () => void; } export type Action = | { type: 'FETCH_INIT'; payload: string } - | { type: 'FETCH_CASE_COUNT_SUCCESS'; payload: Partial } - | { type: 'FETCH_CASES_SUCCESS'; payload: AllCases } + | { + type: 'FETCH_CASES_SUCCESS'; + payload: AllCases; + } | { type: 'FETCH_FAILURE'; payload: string } | { type: 'FETCH_UPDATE_CASE_SUCCESS' } | { type: 'UPDATE_FILTER_OPTIONS'; payload: FilterOptions } @@ -55,20 +52,11 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS ...state, loading: state.loading.filter(e => e !== 'caseUpdate'), }; - case 'FETCH_CASE_COUNT_SUCCESS': - return { - ...state, - caseCount: { - ...state.caseCount, - ...action.payload, - }, - loading: state.loading.filter(e => e !== 'caseCount'), - }; case 'FETCH_CASES_SUCCESS': return { ...state, - isError: false, data: action.payload, + isError: false, loading: state.loading.filter(e => e !== 'cases'), }; case 'FETCH_FAILURE': @@ -102,13 +90,20 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS const initialData: AllCases = { cases: [], + countClosedCases: null, + countOpenCases: null, page: 0, perPage: 0, total: 0, }; interface UseGetCases extends UseGetCasesState { - dispatchUpdateCaseProperty: ({ updateKey, updateValue, caseId, version }: UpdateCase) => void; - getCaseCount: (caseState: keyof CaseCount) => void; + dispatchUpdateCaseProperty: ({ + updateKey, + updateValue, + caseId, + version, + refetchCasesStatus, + }: UpdateCase) => void; refetchCases: (filters: FilterOptions, queryParams: QueryParams) => void; setFilters: (filters: FilterOptions) => void; setQueryParams: (queryParams: QueryParams) => void; @@ -116,14 +111,11 @@ interface UseGetCases extends UseGetCasesState { } export const useGetCases = (): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { - caseCount: { - open: 0, - closed: 0, - }, data: initialData, filterOptions: { search: '', - state: 'open', + reporters: [], + status: 'open', tags: [], }, isError: false, @@ -187,35 +179,8 @@ export const useGetCases = (): UseGetCases => { state.filterOptions, ]); - const getCaseCount = useCallback((caseState: keyof CaseCount) => { - let didCancel = false; - const fetchData = async () => { - dispatch({ type: 'FETCH_INIT', payload: 'caseCount' }); - try { - const response = await getCases({ - filterOptions: { search: '', state: caseState, tags: [] }, - }); - if (!didCancel) { - dispatch({ - type: 'FETCH_CASE_COUNT_SUCCESS', - payload: { [caseState]: response.total }, - }); - } - } catch (error) { - if (!didCancel) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: 'FETCH_FAILURE', payload: 'caseCount' }); - } - } - }; - fetchData(); - return () => { - didCancel = true; - }; - }, []); - const dispatchUpdateCaseProperty = useCallback( - ({ updateKey, updateValue, caseId, version }: UpdateCase) => { + ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { let didCancel = false; const fetchData = async () => { dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); @@ -228,8 +193,7 @@ export const useGetCases = (): UseGetCases => { if (!didCancel) { dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); fetchCases(state.filterOptions, state.queryParams); - getCaseCount('open'); - getCaseCount('closed'); + refetchCasesStatus(); } } catch (error) { if (!didCancel) { @@ -248,14 +212,11 @@ export const useGetCases = (): UseGetCases => { const refetchCases = useCallback(() => { fetchCases(state.filterOptions, state.queryParams); - getCaseCount('open'); - getCaseCount('closed'); }, [state.filterOptions, state.queryParams]); return { ...state, dispatchUpdateCaseProperty, - getCaseCount, refetchCases, setFilters, setQueryParams, diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx new file mode 100644 index 0000000000000..7f56d27ef160e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases_status.tsx @@ -0,0 +1,82 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getCasesStatus } from './api'; +import * as i18n from './translations'; +import { CasesStatus } from './types'; + +interface CasesStatusState extends CasesStatus { + isLoading: boolean; + isError: boolean; +} + +const initialData: CasesStatusState = { + countClosedCases: null, + countOpenCases: null, + isLoading: true, + isError: false, +}; + +interface UseGetCasesStatus extends CasesStatusState { + fetchCasesStatus: () => void; +} + +export const useGetCasesStatus = (): UseGetCasesStatus => { + const [casesStatusState, setCasesStatusState] = useState(initialData); + const [, dispatchToaster] = useStateToaster(); + + const fetchCasesStatus = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setCasesStatusState({ + ...casesStatusState, + isLoading: true, + }); + try { + const response = await getCasesStatus(abortCtrl.signal); + if (!didCancel) { + setCasesStatusState({ + ...response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setCasesStatusState({ + countClosedCases: 0, + countOpenCases: 0, + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [casesStatusState]); + + useEffect(() => { + fetchCasesStatus(); + }, []); + + return { + ...casesStatusState, + fetchCasesStatus, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx new file mode 100644 index 0000000000000..6974000414a06 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_reporters.tsx @@ -0,0 +1,82 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; + +import { User } from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getReporters } from './api'; +import * as i18n from './translations'; + +interface ReportersState { + reporters: string[]; + respReporters: User[]; + isLoading: boolean; + isError: boolean; +} + +const initialData: ReportersState = { + reporters: [], + respReporters: [], + isLoading: true, + isError: false, +}; + +interface UseGetReporters extends ReportersState { + fetchReporters: () => void; +} + +export const useGetReporters = (): UseGetReporters => { + const [reportersState, setReporterState] = useState(initialData); + + const [, dispatchToaster] = useStateToaster(); + + const fetchReporters = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setReporterState({ + ...reportersState, + isLoading: true, + }); + try { + const response = await getReporters(abortCtrl.signal); + if (!didCancel) { + setReporterState({ + reporters: response.map(r => r.full_name ?? r.username ?? 'N/A'), + respReporters: response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setReporterState({ + reporters: [], + respReporters: [], + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [reportersState]); + + useEffect(() => { + fetchReporters(); + }, []); + return { ...reportersState, fetchReporters }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx index 14b9e78846906..817101cf5e663 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -63,7 +63,7 @@ export const usePostCase = (): UsePostCase => { let cancel = false; try { dispatch({ type: 'FETCH_INIT' }); - const response = await postCase({ ...data, state: 'open' }); + const response = await postCase({ ...data, status: 'open' }); if (!cancel) { dispatch({ type: 'FETCH_SUCCESS', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 2b1081b9b901c..afcbe20fa791a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -85,7 +85,7 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); + dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { if (!cancel) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 6a0da7618c383..ea297f6930fe3 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -10,10 +10,14 @@ import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { + CasesFindResponse, + CasesFindResponseRt, CaseResponse, CaseResponseRt, CasesResponse, CasesResponseRt, + CasesStatusResponseRt, + CasesStatusResponse, throwErrors, CommentResponse, CommentResponseRt, @@ -46,20 +50,31 @@ export const convertToCamelCase = (snakeCase: T): U => return acc; }, {} as U); -export const convertAllCasesToCamel = (snakeCases: CasesResponse): AllCases => ({ +export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): AllCases => ({ cases: snakeCases.cases.map(snakeCase => convertToCamelCase(snakeCase)), + countClosedCases: snakeCases.count_closed_cases, + countOpenCases: snakeCases.count_open_cases, page: snakeCases.page, perPage: snakeCases.per_page, total: snakeCases.total, }); +export const decodeCasesStatusResponse = (respCase?: CasesStatusResponse) => + pipe( + CasesStatusResponseRt.decode(respCase), + fold(throwErrors(createToasterPlainError), identity) + ); + export const createToasterPlainError = (message: string) => new ToasterError([message]); export const decodeCaseResponse = (respCase?: CaseResponse) => pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); -export const decodeCasesResponse = (respCases?: CasesResponse) => - pipe(CasesResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity)); +export const decodeCasesResponse = (respCase?: CasesResponse) => + pipe(CasesResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity)); + +export const decodeCasesFindResponse = (respCases?: CasesFindResponse) => + pipe(CasesFindResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity)); export const decodeCommentResponse = (respComment?: CommentResponse) => pipe(CommentResponseRt.decode(respComment), fold(throwErrors(createToasterPlainError), identity)); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index bc6dfe4af25ff..433e1cb17da02 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -9,6 +9,8 @@ import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; export const useGetCasesMockState: UseGetCasesState = { data: { + countClosedCases: 0, + countOpenCases: 0, cases: [ { id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', @@ -17,7 +19,7 @@ export const useGetCasesMockState: UseGetCasesState = { commentIds: [], comments: [], description: 'Security banana Issue', - state: 'open', + status: 'open', tags: ['defacement'], title: 'Another horrible breach', updatedAt: '2020-02-13T19:44:23.627Z', @@ -30,7 +32,7 @@ export const useGetCasesMockState: UseGetCasesState = { commentIds: [], comments: [], description: 'Security banana Issue', - state: 'open', + status: 'open', tags: ['phishing'], title: 'Bad email', updatedAt: '2020-02-13T19:44:13.328Z', @@ -43,7 +45,7 @@ export const useGetCasesMockState: UseGetCasesState = { commentIds: [], comments: [], description: 'Security banana Issue', - state: 'open', + status: 'open', tags: ['phishing'], title: 'Bad email', updatedAt: '2020-02-13T19:44:11.328Z', @@ -56,7 +58,7 @@ export const useGetCasesMockState: UseGetCasesState = { commentIds: [], comments: [], description: 'Security banana Issue', - state: 'closed', + status: 'closed', tags: ['phishing'], title: 'Uh oh', updatedAt: '2020-02-18T21:32:24.056Z', @@ -69,7 +71,7 @@ export const useGetCasesMockState: UseGetCasesState = { commentIds: [], comments: [], description: 'Security banana Issue', - state: 'open', + status: 'open', tags: ['phishing'], title: 'Uh oh', updatedAt: '2020-02-13T19:44:01.901Z', @@ -80,10 +82,6 @@ export const useGetCasesMockState: UseGetCasesState = { perPage: 5, total: 10, }, - caseCount: { - open: 0, - closed: 0, - }, loading: [], selectedCases: [], isError: false, @@ -93,5 +91,5 @@ export const useGetCasesMockState: UseGetCasesState = { sortField: SortFieldCase.createdAt, sortOrder: 'desc', }, - filterOptions: { search: '', tags: [], state: 'open' }, + filterOptions: { search: '', reporters: [], tags: [], status: 'open' }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx index 33a1953b9d2f8..6253d431f8401 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx @@ -12,7 +12,7 @@ import { UpdateCase } from '../../../../containers/case/use_get_cases'; interface GetActions { caseStatus: string; - dispatchUpdate: Dispatch; + dispatchUpdate: Dispatch>; deleteCaseOnClick: (deleteCase: Case) => void; } @@ -36,7 +36,7 @@ export const getActions = ({ name: i18n.CLOSE_CASE, onClick: (theCase: Case) => dispatchUpdate({ - updateKey: 'state', + updateKey: 'status', updateValue: 'closed', caseId: theCase.id, version: theCase.version, @@ -50,7 +50,7 @@ export const getActions = ({ name: i18n.REOPEN_CASE, onClick: (theCase: Case) => dispatchUpdate({ - updateKey: 'state', + updateKey: 'status', updateValue: 'open', caseId: theCase.id, version: theCase.version, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index db3313d843547..5859e6bbce263 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -45,7 +45,7 @@ export const getCasesColumns = ( const caseDetailsLinkComponent = ( {theCase.title} ); - return theCase.state === 'open' ? ( + return theCase.status === 'open' ? ( caseDetailsLinkComponent ) : ( <> @@ -72,7 +72,9 @@ export const getCasesColumns = ( name={createdBy.fullName ? createdBy.fullName : createdBy.username} size="s" /> - {createdBy.username} + + {createdBy.fullName ?? createdBy.username ?? 'N/A'} + ); } diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 10786940eee7f..001acc1d4d36e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -16,7 +16,6 @@ import { wait } from '../../../../lib/helpers'; describe('AllCases', () => { const dispatchUpdateCaseProperty = jest.fn(); - const getCaseCount = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); const setQueryParams = jest.fn(); @@ -26,7 +25,6 @@ describe('AllCases', () => { jest.spyOn(apiHook, 'useGetCases').mockReturnValue({ ...useGetCasesMockState, dispatchUpdateCaseProperty, - getCaseCount, refetchCases, setFilters, setQueryParams, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 1d22f6a246960..486c7e4da9d3b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -23,13 +23,11 @@ import * as i18n from './translations'; import { getCasesColumns } from './columns'; import { Case, FilterOptions, SortFieldCase } from '../../../../containers/case/types'; - -import { useGetCases } from '../../../../containers/case/use_get_cases'; +import { useGetCases, UpdateCase } from '../../../../containers/case/use_get_cases'; +import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; import { Panel } from '../../../../components/panel'; -import { CasesTableFilters } from './table_filters'; - import { UtilityBar, UtilityBarAction, @@ -38,11 +36,14 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; + import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { OpenClosedStats } from '../open_closed_stats'; + import { getActions } from './actions'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { CasesTableFilters } from './table_filters'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -75,11 +76,15 @@ const getSortField = (field: string): SortFieldCase => { }; export const AllCases = React.memo(() => { const { - caseCount, + countClosedCases, + countOpenCases, + isLoading: isCasesStatusLoading, + fetchCasesStatus, + } = useGetCasesStatus(); + const { data, dispatchUpdateCaseProperty, filterOptions, - getCaseCount, loading, queryParams, selectedCases, @@ -102,6 +107,7 @@ export const AllCases = React.memo(() => { useEffect(() => { if (isDeleted) { refetchCases(filterOptions, queryParams); + fetchCasesStatus(); dispatchResetIsDeleted(); } }, [isDeleted, filterOptions, queryParams]); @@ -156,20 +162,27 @@ export const AllCases = React.memo(() => { closePopover, deleteCasesAction: toggleBulkDeleteModal, selectedCaseIds, - caseStatus: filterOptions.state, + caseStatus: filterOptions.status, })} /> ), - [selectedCaseIds, filterOptions.state] + [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal] ); + const handleDispatchUpdate = useCallback( + (args: Omit) => { + dispatchUpdateCaseProperty({ ...args, refetchCasesStatus: fetchCasesStatus }); + }, + [dispatchUpdateCaseProperty, fetchCasesStatus] + ); + const actions = useMemo( () => getActions({ - caseStatus: filterOptions.state, + caseStatus: filterOptions.status, deleteCaseOnClick: toggleDeleteModal, - dispatchUpdate: dispatchUpdateCaseProperty, + dispatchUpdate: handleDispatchUpdate, }), - [filterOptions.state] + [filterOptions.status, toggleDeleteModal, handleDispatchUpdate] ); const tableOnChangeCallback = useCallback( @@ -201,7 +214,7 @@ export const AllCases = React.memo(() => { [filterOptions, setFilters] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [filterOptions.state]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, @@ -233,18 +246,16 @@ export const AllCases = React.memo(() => { -1} + caseCount={countOpenCases} + caseStatus={'open'} + isLoading={isCasesStatusLoading} /> -1} + caseCount={countClosedCases} + caseStatus={'closed'} + isLoading={isCasesStatusLoading} /> @@ -266,11 +277,14 @@ export const AllCases = React.memo(() => { )} {isCasesLoading && isDataEmpty ? ( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx index 9356577fd1888..a71ad1c45a980 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx @@ -17,9 +17,12 @@ import * as i18n from './translations'; import { FilterOptions } from '../../../../containers/case/types'; import { useGetTags } from '../../../../containers/case/use_get_tags'; +import { useGetReporters } from '../../../../containers/case/use_get_reporters'; import { FilterPopover } from '../../../../components/filter_popover'; interface CasesTableFiltersProps { + countClosedCases: number | null; + countOpenCases: number | null; onFilterChanged: (filterOptions: Partial) => void; initial: FilterOptions; } @@ -31,14 +34,35 @@ interface CasesTableFiltersProps { * @param onFilterChanged change listener to be notified on filter changes */ +const defaultInitial = { search: '', reporters: [], status: 'open', tags: [] }; + const CasesTableFiltersComponent = ({ + countClosedCases, + countOpenCases, onFilterChanged, - initial = { search: '', tags: [], state: 'open' }, + initial = defaultInitial, }: CasesTableFiltersProps) => { + const [selectedReporters, setselectedReporters] = useState( + initial.reporters.map(r => r.full_name ?? r.username) + ); const [search, setSearch] = useState(initial.search); const [selectedTags, setSelectedTags] = useState(initial.tags); - const [showOpenCases, setShowOpenCases] = useState(initial.state === 'open'); + const [showOpenCases, setShowOpenCases] = useState(initial.status === 'open'); const { tags } = useGetTags(); + const { reporters, respReporters } = useGetReporters(); + + const handleSelectedReporters = useCallback( + newReporters => { + if (!isEqual(newReporters, selectedReporters)) { + setselectedReporters(newReporters); + const reportersObj = respReporters.filter( + r => newReporters.includes(r.username) || newReporters.includes(r.full_name) + ); + onFilterChanged({ reporters: reportersObj }); + } + }, + [selectedReporters, respReporters] + ); const handleSelectedTags = useCallback( newTags => { @@ -47,7 +71,7 @@ const CasesTableFiltersComponent = ({ onFilterChanged({ tags: newTags }); } }, - [search, selectedTags] + [selectedTags] ); const handleOnSearch = useCallback( newSearch => { @@ -57,13 +81,13 @@ const CasesTableFiltersComponent = ({ onFilterChanged({ search: trimSearch }); } }, - [search, selectedTags] + [search] ); const handleToggleFilter = useCallback( showOpen => { if (showOpen !== showOpenCases) { setShowOpenCases(showOpen); - onFilterChanged({ state: showOpen ? 'open' : 'closed' }); + onFilterChanged({ status: showOpen ? 'open' : 'closed' }); } }, [showOpenCases] @@ -88,18 +112,20 @@ const CasesTableFiltersComponent = ({ onClick={handleToggleFilter.bind(null, true)} > {i18n.OPEN_CASES} + {countOpenCases != null ? ` (${countOpenCases})` : ''} {i18n.CLOSED_CASES} + {countClosedCases != null ? ` (${countClosedCases})` : ''} {}} - selectedOptions={[]} - options={[]} + onSelectedOptionsChanged={handleSelectedReporters} + selectedOptions={selectedReporters} + options={reporters} optionsEmptyLabel={i18n.NO_REPORTERS_AVAILABLE} /> { ).toEqual(data.title); expect( wrapper - .find(`[data-test-subj="case-view-state"]`) + .find(`[data-test-subj="case-view-status"]`) .first() .text() - ).toEqual(data.state); + ).toEqual(data.status); expect( wrapper .find(`[data-test-subj="case-view-tag-list"] .euiBadge__text`) @@ -77,11 +77,11 @@ describe('CaseView ', () => { ); wrapper - .find('input[data-test-subj="toggle-case-state"]') + .find('input[data-test-subj="toggle-case-status"]') .simulate('change', { target: { value: false } }); expect(updateCaseProperty).toBeCalledWith({ - updateKey: 'state', + updateKey: 'status', updateValue: 'closed', }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 080cbdc143593..5ff542d208905 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -95,22 +95,22 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => updateValue: tagsUpdate, }); break; - case 'state': - const stateUpdate = getTypedPayload(updateValue); - if (caseData.state !== updateValue) { + case 'status': + const statusUpdate = getTypedPayload(updateValue); + if (caseData.status !== updateValue) { updateCaseProperty({ - updateKey: 'state', - updateValue: stateUpdate, + updateKey: 'status', + updateValue: statusUpdate, }); } default: return null; } }, - [updateCaseProperty, caseData.state] + [updateCaseProperty, caseData.status] ); - const toggleStateCase = useCallback( - e => onUpdateField('state', e.target.checked ? 'open' : 'closed'), + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), [onUpdateField] ); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); @@ -185,10 +185,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => {i18n.STATUS} - {caseData.state} + {caseData.status} @@ -208,12 +208,12 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index f49f488e30fbd..3b9af8349437e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -47,7 +47,7 @@ const MySpinner = styled(EuiLoadingSpinner)` const initialCaseValue: CaseRequest = { description: '', - state: 'open', + status: 'open', tags: [], title: '', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx index 8d0fafdfc36ca..75f1d4d911518 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/open_closed_stats/index.tsx @@ -4,35 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Dispatch, useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui'; import * as i18n from '../all_cases/translations'; -import { CaseCount } from '../../../../containers/case/use_get_cases'; export interface Props { - caseCount: CaseCount; - caseState: 'open' | 'closed'; - getCaseCount: Dispatch; + caseCount: number | null; + caseStatus: 'open' | 'closed'; isLoading: boolean; } -export const OpenClosedStats = React.memo( - ({ caseCount, caseState, getCaseCount, isLoading }) => { - useEffect(() => { - getCaseCount(caseState); - }, [caseState]); - - const openClosedStats = useMemo( - () => [ - { - title: caseState === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES, - description: isLoading ? : caseCount[caseState], - }, - ], - [caseCount, caseState, isLoading] - ); - return ; - } -); +export const OpenClosedStats = React.memo(({ caseCount, caseStatus, isLoading }) => { + const openClosedStats = useMemo( + () => [ + { + title: caseStatus === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES, + description: isLoading ? : caseCount ?? 'N/A', + }, + ], + [caseCount, caseStatus, isLoading] + ); + return ; +}); OpenClosedStats.displayName = 'OpenClosedStats'; diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 1bf39e6616480..68a222cb656ed 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -6,12 +6,16 @@ import * as rt from 'io-ts'; -import { CommentResponseRt } from './comment'; +import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; +import { CommentResponseRt } from './comment'; +import { CasesStatusResponseRt } from './status'; + +const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ description: rt.string, - state: rt.union([rt.literal('open'), rt.literal('closed')]), + status: StatusRt, tags: rt.array(rt.string), title: rt.string, }); @@ -29,6 +33,20 @@ export const CaseAttributesRt = rt.intersection([ export const CaseRequestRt = CaseBasicRt; +export const CasesFindRequestRt = rt.partial({ + tags: rt.union([rt.array(rt.string), rt.string]), + status: StatusRt, + reporters: rt.union([rt.array(rt.string), rt.string]), + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + export const CaseResponseRt = rt.intersection([ CaseAttributesRt, rt.type({ @@ -40,20 +58,28 @@ export const CaseResponseRt = rt.intersection([ }), ]); -export const CasesResponseRt = rt.type({ - cases: rt.array(CaseResponseRt), - page: rt.number, - per_page: rt.number, - total: rt.number, -}); +export const CasesFindResponseRt = rt.intersection([ + rt.type({ + cases: rt.array(CaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, + }), + CasesStatusResponseRt, +]); export const CasePatchRequestRt = rt.intersection([ rt.partial(CaseRequestRt.props), rt.type({ id: rt.string, version: rt.string }), ]); +export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); +export const CasesResponseRt = rt.array(CaseResponseRt); + export type CaseAttributes = rt.TypeOf; export type CaseRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; +export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; +export type CasesPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 83e249e3257c4..5a355c631f396 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -6,3 +6,4 @@ export * from './case'; export * from './comment'; +export * from './status'; diff --git a/x-pack/plugins/case/common/api/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts new file mode 100644 index 0000000000000..984181da8cdee --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/status.ts @@ -0,0 +1,14 @@ +/* + * 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 * as rt from 'io-ts'; + +export const CasesStatusResponseRt = rt.type({ + count_open_cases: rt.number, + count_closed_cases: rt.number, +}); + +export type CasesStatusResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/index.ts b/x-pack/plugins/case/common/api/index.ts index 3e94d91569ca5..fd77f46bef109 100644 --- a/x-pack/plugins/case/common/api/index.ts +++ b/x-pack/plugins/case/common/api/index.ts @@ -7,3 +7,4 @@ export * from './cases'; export * from './runtime_types'; export * from './saved_object'; +export * from './user'; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 0da859649a34e..fac8edd0ebea1 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -8,7 +8,7 @@ import * as rt from 'io-ts'; import { either } from 'fp-ts/lib/Either'; -const NumberFromString = new rt.Type( +export const NumberFromString = new rt.Type( 'NumberFromString', rt.number.is, (u, c) => diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts index bf5cde7af03f3..ed44791c4e04d 100644 --- a/x-pack/plugins/case/common/api/user.ts +++ b/x-pack/plugins/case/common/api/user.ts @@ -7,6 +7,10 @@ import * as rt from 'io-ts'; export const UserRT = rt.type({ - full_name: rt.union([rt.undefined, rt.string, rt.null]), - username: rt.union([rt.string, rt.null]), + full_name: rt.union([rt.undefined, rt.string]), + username: rt.string, }); + +export const UsersRt = rt.array(UserRT); + +export type User = rt.TypeOf; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 7c97adc1b31bf..5051f78a47cce 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; +import { + SavedObjectsClientContract, + SavedObjectsErrorHelpers, + SavedObjectsBulkGetObject, + SavedObjectsBulkUpdateObject, +} from 'src/core/server'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../../saved_object_types'; @@ -16,6 +21,47 @@ export const createMockSavedObjectsRepository = ({ caseCommentSavedObject?: any[]; }) => { const mockSavedObjectsClientContract = ({ + bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { + return { + saved_objects: objects.map(({ id, type }) => { + if (type === CASE_COMMENT_SAVED_OBJECT) { + const result = caseCommentSavedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } + const result = caseSavedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + }; + }), + bulkUpdate: jest.fn((objects: Array>) => { + return { + saved_objects: objects.map(({ id, type, attributes }) => { + if (type === CASE_COMMENT_SAVED_OBJECT) { + if (!caseCommentSavedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + } else if (type === CASE_SAVED_OBJECT) { + if (!caseSavedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + } + + return { + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes, + }; + }), + }; + }), get: jest.fn((type, id) => { if (type === CASE_COMMENT_SAVED_OBJECT) { const result = caseCommentSavedObject.filter(s => s.id === id); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 3701e4f14e8b3..1e1965f83ff68 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -20,7 +20,7 @@ export const mockCases: Array> = [ }, description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', - state: 'open', + status: 'open', tags: ['defacement'], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { @@ -44,7 +44,7 @@ export const mockCases: Array> = [ }, description: 'Oh no, a bad meanie destroying data!', title: 'Damaging Data Destruction Detected', - state: 'open', + status: 'open', tags: ['Data Destruction'], updated_at: '2019-11-25T22:32:00.900Z', updated_by: { @@ -68,7 +68,7 @@ export const mockCases: Array> = [ }, description: 'Oh no, a bad meanie going LOLBins all over the place!', title: 'Another bad one', - state: 'open', + status: 'open', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', updated_by: { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 38445cdda8f50..0166ba89eb76c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -65,6 +65,7 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { updated_at: new Date().toISOString(), updated_by: { full_name, username }, }, + version: query.version, }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts similarity index 90% rename from x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts rename to x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index ec56c32f91745..7ce37d2569e57 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_all_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -13,12 +13,12 @@ import { createRouteContext, mockCases, } from '../__fixtures__'; -import { initGetAllCasesApi } from './get_all_cases'; +import { initFindCasesApi } from './find_cases'; describe('GET all cases', () => { let routeHandler: RequestHandler; beforeAll(async () => { - routeHandler = await createRoute(initGetAllCasesApi, 'get'); + routeHandler = await createRoute(initFindCasesApi, 'get'); }); it(`gets all the cases`, async () => { const request = httpServerMock.createKibanaRequest({ diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts new file mode 100644 index 0000000000000..76a1992c64270 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -0,0 +1,110 @@ +/* + * 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 Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { isEmpty } from 'lodash'; +import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api'; +import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; +import { RouteDeps } from '../types'; +import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; + +const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => + filters?.filter(i => i !== '').join(` ${operator} `); + +const getStatusFilter = (status: 'open' | 'closed', appendFilter?: string) => + `${CASE_SAVED_OBJECT}.attributes.status: ${status}${ + !isEmpty(appendFilter) ? ` AND ${appendFilter}` : '' + }`; + +const buildFilter = ( + filters: string | string[] | undefined, + field: string, + operator: 'OR' | 'AND' +): string => + filters != null && filters.length > 0 + ? Array.isArray(filters) + ? filters + .map(filter => `${CASE_SAVED_OBJECT}.attributes.${field}: ${filter}`) + ?.join(` ${operator} `) + : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` + : ''; + +export function initFindCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/_find', + validate: { + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const queryParams = pipe( + CasesFindRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { tags, reporters, status, ...query } = queryParams; + const tagsFilter = buildFilter(tags, 'tags', 'OR'); + const reportersFilters = buildFilter(reporters, 'created_by.username', 'OR'); + + const myFilters = combineFilters([tagsFilter, reportersFilters], 'AND'); + const filter = status != null ? getStatusFilter(status, myFilters) : myFilters; + + const args = queryParams + ? { + client, + options: { + ...query, + filter, + sortField: sortToSnake(query.sortField ?? ''), + }, + } + : { + client, + }; + + const argsOpenCases = { + client, + options: { + fields: [], + page: 1, + perPage: 1, + filter: getStatusFilter('open', myFilters), + }, + }; + + const argsClosedCases = { + client, + options: { + fields: [], + page: 1, + perPage: 1, + filter: getStatusFilter('closed', myFilters), + }, + }; + const [cases, openCases, closesCases] = await Promise.all([ + caseService.findCases(args), + caseService.findCases(argsOpenCases), + caseService.findCases(argsClosedCases), + ]); + return response.ok({ + body: CasesFindResponseRt.encode( + transformCases(cases, openCases.total ?? 0, closesCases.total ?? 0) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts deleted file mode 100644 index 96b8e8c110c01..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/get_all_cases.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 Boom from 'boom'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { CasesResponseRt, SavedObjectFindOptionsRt, throwErrors } from '../../../../common/api'; -import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps } from '../types'; - -export function initGetAllCasesApi({ caseService, router }: RouteDeps) { - router.get( - { - path: '/api/cases/_find', - validate: { - query: escapeHatch, - }, - }, - async (context, request, response) => { - try { - const query = pipe( - SavedObjectFindOptionsRt.decode(request.query), - fold(throwErrors(Boom.badRequest), identity) - ); - - const args = query - ? { - client: context.core.savedObjects.client, - options: { - ...query, - sortField: sortToSnake(query.sortField ?? ''), - }, - } - : { - client: context.core.savedObjects.client, - }; - const cases = await caseService.getAllCases(args); - return response.ok({ - body: CasesResponseRt.encode(transformCases(cases)), - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts new file mode 100644 index 0000000000000..3bf46cadc83c8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -0,0 +1,37 @@ +/* + * 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 { difference, get } from 'lodash'; + +import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; + +export const getCaseToUpdate = ( + currentCase: CaseAttributes, + queryCase: CasePatchRequest +): CasePatchRequest => + Object.entries(queryCase).reduce( + (acc, [key, value]) => { + const currentValue = get(currentCase, key); + if ( + currentValue != null && + Array.isArray(value) && + Array.isArray(currentValue) && + difference(value, currentValue).length !== 0 + ) { + return { + ...acc, + [key]: value, + }; + } else if (currentValue != null && value !== currentValue) { + return { + ...acc, + [key]: value, + }; + } + return acc; + }, + { id: queryCase.id, version: queryCase.version } + ); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_case.ts b/x-pack/plugins/case/server/routes/api/cases/patch_case.ts deleted file mode 100644 index eccede372c688..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/patch_case.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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 Boom from 'boom'; -import { difference, get } from 'lodash'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { - CaseAttributes, - CasePatchRequestRt, - throwErrors, - CaseResponseRt, -} from '../../../../common/api'; -import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; -import { RouteDeps } from '../types'; - -export function initPatchCaseApi({ caseService, router }: RouteDeps) { - router.patch( - { - path: '/api/cases', - validate: { - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - const query = pipe( - CasePatchRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: query.id, - }); - - if (query.version !== myCase.version) { - throw Boom.conflict( - 'This case has been updated. Please refresh before saving additional updates.' - ); - } - const currentCase: CaseAttributes = myCase.attributes; - const updateCase: Partial = Object.entries(query).reduce( - (acc, [key, value]) => { - const currentValue = get(currentCase, key); - if ( - currentValue != null && - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { - return { - ...acc, - [key]: value, - }; - } else if (currentValue != null && value !== currentValue) { - return { - ...acc, - [key]: value, - }; - } - return acc; - }, - {} - ); - if (Object.keys(updateCase).length > 0) { - const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; - const updatedCase = await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: query.id, - updatedAttributes: { - ...updateCase, - updated_at: new Date().toISOString(), - updated_by: { full_name, username }, - }, - }); - return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - references: myCase.references, - }) - ), - }); - } - throw Boom.notAcceptable('All update fields are identical to current version.'); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts similarity index 62% rename from x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts rename to x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 42fe9967ad0a0..7ab7212d2f436 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -14,21 +14,29 @@ import { mockCases, mockCaseComments, } from '../__fixtures__'; -import { initPatchCaseApi } from './patch_case'; +import { initPatchCasesApi } from './patch_cases'; -describe('PATCH case', () => { +describe('PATCH cases', () => { let routeHandler: RequestHandler; beforeAll(async () => { - routeHandler = await createRoute(initPatchCaseApi, 'patch'); + routeHandler = await createRoute(initPatchCasesApi, 'patch'); + const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; + spyOnDate.mockImplementation(() => ({ + toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), + })); }); it(`Patch a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', body: { - id: 'mock-id-1', - state: 'closed', - version: 'WzAsMV0=', + cases: [ + { + id: 'mock-id-1', + status: 'closed', + version: 'WzAsMV0=', + }, + ], }, }); @@ -40,17 +48,35 @@ describe('PATCH case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(typeof response.payload.updated_at).toBe('string'); - expect(response.payload.state).toEqual('closed'); + expect(response.payload).toEqual([ + { + comment_ids: ['mock-comment-1'], + comments: [], + created_at: '2019-11-25T21:54:48.952Z', + created_by: { 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' }, + version: 'WzE3LDFd', + }, + ]); }); it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', body: { - id: 'mock-id-1', - case: { state: 'closed' }, - version: 'badv=', + cases: [ + { + id: 'mock-id-1', + case: { status: 'closed' }, + version: 'badv=', + }, + ], }, }); @@ -68,9 +94,13 @@ describe('PATCH case', () => { path: '/api/cases', method: 'patch', body: { - id: 'mock-id-1', - case: { state: 'open' }, - version: 'WzAsMV0=', + cases: [ + { + id: 'mock-id-1', + case: { status: 'open' }, + version: 'WzAsMV0=', + }, + ], }, }); @@ -89,9 +119,13 @@ describe('PATCH case', () => { path: '/api/cases', method: 'patch', body: { - id: 'mock-id-does-not-exist', - state: 'closed', - version: 'WzAsMV0=', + cases: [ + { + id: 'mock-id-does-not-exist', + status: 'closed', + version: 'WzAsMV0=', + }, + ], }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts new file mode 100644 index 0000000000000..3fd8c2a1627ab --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -0,0 +1,103 @@ +/* + * 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 Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CasesPatchRequestRt, + throwErrors, + CasesResponseRt, + CasePatchRequest, +} from '../../../../common/api'; +import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; +import { RouteDeps } from '../types'; +import { getCaseToUpdate } from './helpers'; + +export function initPatchCasesApi({ caseService, router }: RouteDeps) { + router.patch( + { + path: '/api/cases', + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CasesPatchRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + const myCases = await caseService.getCases({ + client: context.core.savedObjects.client, + caseIds: query.cases.map(q => q.id), + }); + const conflictedCases = query.cases.filter(q => { + const myCase = myCases.saved_objects.find(c => c.id === q.id); + return myCase == null || myCase?.version !== q.version; + }); + if (conflictedCases.length > 0) { + throw Boom.conflict( + `These cases ${conflictedCases + .map(c => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } + const updateCases: CasePatchRequest[] = query.cases.map(thisCase => { + const currentCase = myCases.saved_objects.find(c => c.id === thisCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, thisCase) + : { id: thisCase.id, version: thisCase.version }; + }); + const updateFilterCases = updateCases.filter(updateCase => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); + if (updateFilterCases.length > 0) { + const updatedBy = await caseService.getUser({ request, response }); + const { 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; + return { + caseId, + updatedAttributes: { + ...updateCaseAttributes, + updated_at: updatedDt, + updated_by: { full_name, username }, + }, + version, + }; + }), + }); + const returnUpdatedCase = myCases.saved_objects + .filter(myCase => + updatedCases.saved_objects.some(updatedCase => updatedCase.id === myCase.id) + ) + .map(myCase => { + const updatedCase = updatedCases.saved_objects.find(c => c.id === myCase.id); + return flattenCaseSavedObject({ + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }); + }); + return response.ok({ + body: CasesResponseRt.encode(returnUpdatedCase), + }); + } + throw Boom.notAcceptable('All update fields are identical to current version.'); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 0d14a659d2c42..5b716e5a2d490 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -27,7 +27,7 @@ describe('POST cases', () => { body: { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', - state: 'open', + status: 'open', tags: ['defacement'], }, }); @@ -50,7 +50,7 @@ describe('POST cases', () => { body: { description: 'Throw an error', title: 'Super Bad Security Issue', - state: 'open', + status: 'open', tags: ['error'], }, }); @@ -74,7 +74,7 @@ describe('POST cases', () => { body: { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', - state: 'open', + status: 'open', tags: ['defacement'], }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts new file mode 100644 index 0000000000000..519bb198f5f9e --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -0,0 +1,28 @@ +/* + * 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 { UsersRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initGetReportersApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/reporters', + validate: {}, + }, + async (context, request, response) => { + try { + const reporters = await caseService.getReporters({ + client: context.core.savedObjects.client, + }); + return response.ok({ body: UsersRt.encode(reporters) }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts new file mode 100644 index 0000000000000..b4fc90d702604 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -0,0 +1,57 @@ +/* + * 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 { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +import { CasesStatusResponseRt } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; + +export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/status', + validate: {}, + }, + async (context, request, response) => { + try { + const argsOpenCases = { + client: context.core.savedObjects.client, + options: { + fields: [], + page: 1, + perPage: 1, + filter: `${CASE_SAVED_OBJECT}.attributes.status: open`, + }, + }; + + const argsClosedCases = { + client: context.core.savedObjects.client, + options: { + fields: [], + page: 1, + perPage: 1, + filter: `${CASE_SAVED_OBJECT}.attributes.status: closed`, + }, + }; + + const [openCases, closesCases] = await Promise.all([ + caseService.findCases(argsOpenCases), + caseService.findCases(argsClosedCases), + ]); + + return response.ok({ + body: CasesStatusResponseRt.encode({ + count_open_cases: openCases.total, + count_closed_cases: closesCases.total, + }), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index b1a2f10dd6f95..ca51f421f4f56 100644 --- a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -14,12 +14,11 @@ export function initGetTagsApi({ caseService, router }: RouteDeps) { validate: {}, }, async (context, request, response) => { - let theCase; try { - theCase = await caseService.getTags({ + const tags = await caseService.getTags({ client: context.core.savedObjects.client, }); - return response.ok({ body: theCase }); + return response.ok({ body: tags }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index f4dca6a64c8d2..cfaef1251bf8c 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -5,9 +5,9 @@ */ import { initDeleteCasesApi } from './cases/delete_cases'; -import { initGetAllCasesApi } from './cases/get_all_cases'; +import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; -import { initPatchCaseApi } from './cases/patch_case'; +import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; import { initDeleteCommentApi } from './cases/comments/delete_comment'; @@ -18,22 +18,33 @@ import { initGetCommentApi } from './cases/comments/get_comment'; import { initPatchCommentApi } from './cases/comments/patch_comment'; import { initPostCommentApi } from './cases/comments/post_comment'; +import { initGetReportersApi } from './cases/reporters/get_reporters'; + +import { initGetCasesStatusApi } from './cases/status/get_status'; + import { initGetTagsApi } from './cases/tags/get_tags'; import { RouteDeps } from './types'; export function initCaseApi(deps: RouteDeps) { + // Cases initDeleteCasesApi(deps); + initFindCasesApi(deps); + initGetCaseApi(deps); + initPatchCasesApi(deps); + initPostCaseApi(deps); + // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); initFindCaseCommentsApi(deps); - initGetAllCasesApi(deps); - initGetCaseApi(deps); initGetCommentApi(deps); initGetAllCommentsApi(deps); - initGetTagsApi(deps); - initPostCaseApi(deps); - initPostCommentApi(deps); - initPatchCaseApi(deps); initPatchCommentApi(deps); + initPostCommentApi(deps); + // Reporters + initGetReportersApi(deps); + // Status + initGetCasesStatusApi(deps); + // Tags + initGetTagsApi(deps); } diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts deleted file mode 100644 index 765f9c722219f..0000000000000 --- a/x-pack/plugins/case/server/routes/api/schema.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 { schema } from '@kbn/config-schema'; - -export const UserSchema = schema.object({ - full_name: schema.maybe(schema.string()), - username: schema.string(), -}); - -export const NewCommentSchema = schema.object({ - comment: schema.string(), -}); - -export const UpdateCommentArguments = schema.object({ - comment: schema.string(), - version: schema.string(), -}); - -export const CommentSchema = schema.object({ - comment: schema.string(), - created_at: schema.string(), - created_by: UserSchema, - updated_at: schema.string(), -}); - -export const UpdatedCommentSchema = schema.object({ - comment: schema.string(), - updated_at: schema.string(), -}); - -export const NewCaseSchema = schema.object({ - description: schema.string(), - state: schema.oneOf([schema.literal('open'), schema.literal('closed')], { defaultValue: 'open' }), - tags: schema.arrayOf(schema.string(), { defaultValue: [] }), - title: schema.string(), -}); - -export const UpdatedCaseSchema = schema.object({ - description: schema.maybe(schema.string()), - state: schema.maybe(schema.oneOf([schema.literal('open'), schema.literal('closed')])), - tags: schema.maybe(schema.arrayOf(schema.string())), - title: schema.maybe(schema.string()), -}); - -export const UpdateCaseArguments = schema.object({ - case: UpdatedCaseSchema, - version: schema.string(), -}); - -export const SavedObjectsFindOptionsSchema = schema.object({ - defaultSearchOperator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), - fields: schema.maybe(schema.arrayOf(schema.string())), - filter: schema.maybe(schema.string()), - page: schema.maybe(schema.number()), - perPage: schema.maybe(schema.number()), - search: schema.maybe(schema.string()), - searchFields: schema.maybe(schema.arrayOf(schema.string())), - sortField: schema.maybe(schema.string()), - sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), -}); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 1252fd19cda02..e8668db5d232f 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -13,6 +13,6 @@ export interface RouteDeps { export enum SortFieldCase { createdAt = 'created_at', - state = 'state', + status = 'status', updatedAt = 'updated_at', } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 920c53f404456..2d73c3aa7976d 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -15,7 +15,7 @@ import { import { CaseRequest, CaseResponse, - CasesResponse, + CasesFindResponse, CaseAttributes, CommentResponse, CommentsResponse, @@ -32,8 +32,8 @@ export const transformNewCase = ({ }: { createdDate: string; newCase: CaseRequest; - full_name?: string | null; - username: string | null; + full_name?: string; + username: string; }): CaseAttributes => ({ comment_ids: [], created_at: createdDate, @@ -46,8 +46,8 @@ export const transformNewCase = ({ interface NewCommentArgs { comment: string; createdDate: string; - full_name?: string | null; - username: string | null; + full_name?: string; + username: string; } export const transformNewComment = ({ comment, @@ -71,11 +71,17 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = (cases: SavedObjectsFindResponse): CasesResponse => ({ +export const transformCases = ( + cases: SavedObjectsFindResponse, + countOpenCases: number, + countClosedCases: number +): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, cases: flattenCaseSavedObjects(cases.saved_objects), + count_open_cases: countOpenCases, + count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( @@ -121,8 +127,8 @@ export const flattenCommentSavedObject = ( export const sortToSnake = (sortField: string): SortFieldCase => { switch (sortField) { - case 'state': - return SortFieldCase.state; + case 'status': + return SortFieldCase.status; case 'createdAt': case 'created_at': return SortFieldCase.createdAt; diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index faed0a3100a42..2aa64528739b1 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -36,7 +36,7 @@ export const caseSavedObjectType: SavedObjectsType = { title: { type: 'keyword', }, - state: { + status: { type: 'keyword', }, tags: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 61b696d45d030..ccb07280028b5 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -13,11 +13,14 @@ import { SavedObjectsFindResponse, SavedObjectsUpdateResponse, SavedObjectReference, + SavedObjectsBulkUpdateResponse, + SavedObjectsBulkResponse, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; -import { CaseAttributes, CommentAttributes, SavedObjectFindOptions } from '../../common/api'; +import { CaseAttributes, CommentAttributes, SavedObjectFindOptions, User } from '../../common/api'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; +import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; interface ClientArgs { @@ -28,11 +31,15 @@ interface GetCaseArgs extends ClientArgs { caseId: string; } +interface GetCasesArgs extends ClientArgs { + caseIds: string[]; +} + interface GetCommentsArgs extends GetCaseArgs { options?: SavedObjectFindOptions; } -interface GetCasesArgs extends ClientArgs { +interface FindCasesArgs extends ClientArgs { options?: SavedObjectFindOptions; } interface GetCommentArgs extends ClientArgs { @@ -46,13 +53,21 @@ interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; } -interface PatchCaseArgs extends ClientArgs { + +interface PatchCase { caseId: string; updatedAttributes: Partial; + version?: string; +} +type PatchCaseArgs = PatchCase & ClientArgs; + +interface PatchCasesArgs extends ClientArgs { + cases: PatchCase[]; } interface UpdateCommentArgs extends ClientArgs { commentId: string; updatedAttributes: Partial; + version?: string; } interface GetUserArgs { @@ -66,15 +81,18 @@ interface CaseServiceDeps { export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; - getAllCases(args: GetCasesArgs): Promise>; + findCases(args: FindCasesArgs): Promise>; getAllCaseComments(args: GetCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; + getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; + getReporters(args: ClientArgs): Promise; getUser(args: GetUserArgs): Promise; postNewCase(args: PostCaseArgs): Promise>; postNewComment(args: PostCommentArgs): Promise>; patchCase(args: PatchCaseArgs): Promise>; + patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; } @@ -108,6 +126,17 @@ export class CaseService { throw error; } }, + getCases: async ({ client, caseIds }: GetCasesArgs) => { + try { + this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); + return await client.bulkGet( + caseIds.map(caseId => ({ type: CASE_SAVED_OBJECT, id: caseId })) + ); + } catch (error) { + this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + throw error; + } + }, getComment: async ({ client, commentId }: GetCommentArgs) => { try { this.log.debug(`Attempting to GET comment ${commentId}`); @@ -117,7 +146,7 @@ export class CaseService { throw error; } }, - getAllCases: async ({ client, options }: GetCasesArgs) => { + findCases: async ({ client, options }: FindCasesArgs) => { try { this.log.debug(`Attempting to GET all cases`); return await client.find({ ...options, type: CASE_SAVED_OBJECT }); @@ -139,6 +168,15 @@ export class CaseService { throw error; } }, + getReporters: async ({ client }: ClientArgs) => { + try { + this.log.debug(`Attempting to GET all reporters`); + return await readReporters({ client }); + } catch (error) { + this.log.debug(`Error on GET all reporters: ${error}`); + throw error; + } + }, getTags: async ({ client }: ClientArgs) => { try { this.log.debug(`Attempting to GET all cases`); @@ -175,21 +213,47 @@ export class CaseService { throw error; } }, - patchCase: async ({ client, caseId, updatedAttributes }: PatchCaseArgs) => { + patchCase: async ({ client, caseId, updatedAttributes, version }: PatchCaseArgs) => { try { this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); + return await client.update( + CASE_SAVED_OBJECT, + caseId, + { ...updatedAttributes }, + { version } + ); } catch (error) { this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); throw error; } }, - patchComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + patchCases: async ({ client, cases }: PatchCasesArgs) => { + try { + this.log.debug(`Attempting to UPDATE case ${cases.map(c => c.caseId).join(', ')}`); + return await client.bulkUpdate( + cases.map(c => ({ + type: CASE_SAVED_OBJECT, + id: c.caseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug(`Error on UPDATE case ${cases.map(c => c.caseId).join(', ')}: ${error}`); + throw error; + } + }, + patchComment: async ({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) => { try { this.log.debug(`Attempting to UPDATE comment ${commentId}`); - return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { - ...updatedAttributes, - }); + return await client.update( + CASE_COMMENT_SAVED_OBJECT, + commentId, + { + ...updatedAttributes, + }, + { version } + ); } catch (error) { this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); throw error; diff --git a/x-pack/plugins/case/server/services/reporters/read_reporters.ts b/x-pack/plugins/case/server/services/reporters/read_reporters.ts new file mode 100644 index 0000000000000..4af5b41fc4dd4 --- /dev/null +++ b/x-pack/plugins/case/server/services/reporters/read_reporters.ts @@ -0,0 +1,46 @@ +/* + * 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 { SavedObject, SavedObjectsClientContract } from 'kibana/server'; + +import { CaseAttributes, User } from '../../../common/api'; +import { CASE_SAVED_OBJECT } from '../../saved_object_types'; + +export const convertToReporters = (caseObjects: Array>): User[] => + caseObjects.reduce((accum, caseObj) => { + if ( + caseObj && + caseObj.attributes && + caseObj.attributes.created_by && + caseObj.attributes.created_by.username && + !accum.some(item => item.username === caseObj.attributes.created_by.username) + ) { + return [...accum, caseObj.attributes.created_by]; + } else { + return accum; + } + }, []); + +export const readReporters = async ({ + client, +}: { + client: SavedObjectsClientContract; + perPage?: number; +}): Promise => { + const firstReporters = await client.find({ + type: CASE_SAVED_OBJECT, + fields: ['created_by'], + page: 1, + perPage: 1, + }); + const reporters = await client.find({ + type: CASE_SAVED_OBJECT, + fields: ['created_by'], + page: 1, + perPage: firstReporters.total, + }); + return convertToReporters(reporters.saved_objects); +}; diff --git a/x-pack/plugins/case/server/services/tags/read_tags.ts b/x-pack/plugins/case/server/services/tags/read_tags.ts index ddb79507b5fef..b706a3c17cabe 100644 --- a/x-pack/plugins/case/server/services/tags/read_tags.ts +++ b/x-pack/plugins/case/server/services/tags/read_tags.ts @@ -9,8 +9,6 @@ import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; import { CaseAttributes } from '../../../common/api'; import { CASE_SAVED_OBJECT } from '../../saved_object_types'; -const DEFAULT_PER_PAGE: number = 1000; - export const convertToTags = (tagObjects: Array>): string[] => tagObjects.reduce((accum, tagObj) => { if (tagObj && tagObj.attributes && tagObj.attributes.tags) { @@ -31,27 +29,24 @@ export const convertTagsToSet = (tagObjects: Array>) // Ref: https://www.elastic.co/guide/en/kibana/master/saved-objects-api.html export const readTags = async ({ client, - perPage = DEFAULT_PER_PAGE, }: { client: SavedObjectsClientContract; perPage?: number; }): Promise => { - const tags = await readRawTags({ client, perPage }); + const tags = await readRawTags({ client }); return tags; }; export const readRawTags = async ({ client, - perPage = DEFAULT_PER_PAGE, }: { client: SavedObjectsClientContract; - perPage?: number; }): Promise => { const firstTags = await client.find({ type: CASE_SAVED_OBJECT, fields: ['tags'], page: 1, - perPage, + perPage: 1, }); const tags = await client.find({ type: CASE_SAVED_OBJECT,