diff --git a/x-pack/plugins/cases/common/api/cases/sub_case.ts b/x-pack/plugins/cases/common/api/cases/sub_case.ts index 4bbdfd5b7d3688..ba6cd6a8affa44 100644 --- a/x-pack/plugins/cases/common/api/cases/sub_case.ts +++ b/x-pack/plugins/cases/common/api/cases/sub_case.ts @@ -79,3 +79,4 @@ export type SubCasesResponse = rt.TypeOf; export type SubCasesFindResponse = rt.TypeOf; export type SubCasePatchRequest = rt.TypeOf; export type SubCasesPatchRequest = rt.TypeOf; +export type SubCasesFindRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/server/client/client.ts b/x-pack/plugins/cases/server/client/client.ts index 5f6cb8851c34cf..702329f7bcca21 100644 --- a/x-pack/plugins/cases/server/client/client.ts +++ b/x-pack/plugins/cases/server/client/client.ts @@ -4,24 +4,29 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; import { CasesClientArgs } from './types'; import { CasesSubClient, createCasesSubClient } from './cases/client'; import { AttachmentsSubClient, createAttachmentsSubClient } from './attachments/client'; import { UserActionsSubClient, createUserActionsSubClient } from './user_actions/client'; import { CasesClientInternal, createCasesClientInternal } from './client_internal'; +import { createSubCasesClient, SubCasesClient } from './sub_cases/client'; +import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; export class CasesClient { private readonly _casesClientInternal: CasesClientInternal; private readonly _cases: CasesSubClient; private readonly _attachments: AttachmentsSubClient; private readonly _userActions: UserActionsSubClient; + private readonly _subCases: SubCasesClient; constructor(args: CasesClientArgs) { this._casesClientInternal = createCasesClientInternal(args); this._cases = createCasesSubClient(args, this, this._casesClientInternal); this._attachments = createAttachmentsSubClient(args, this._casesClientInternal); this._userActions = createUserActionsSubClient(args); + this._subCases = createSubCasesClient(args, this); } public get cases() { @@ -36,6 +41,13 @@ export class CasesClient { return this._userActions; } + public get subCases() { + if (!ENABLE_CASE_CONNECTOR) { + throw new Error('The case connector feature is disabled'); + } + return this._subCases; + } + // TODO: Remove it when all routes will be moved to the cases client. public get casesClientInternal() { return this._casesClientInternal; diff --git a/x-pack/plugins/cases/server/client/sub_cases/client.ts b/x-pack/plugins/cases/server/client/sub_cases/client.ts new file mode 100644 index 00000000000000..aef780ecb3ac95 --- /dev/null +++ b/x-pack/plugins/cases/server/client/sub_cases/client.ts @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; + +import { + caseStatuses, + SubCaseResponse, + SubCaseResponseRt, + SubCasesFindRequest, + SubCasesFindResponse, + SubCasesFindResponseRt, + SubCasesPatchRequest, + SubCasesResponse, +} from '../../../common/api'; +import { CasesClientArgs } from '..'; +import { flattenSubCaseSavedObject, transformSubCases } from '../../routes/api/utils'; +import { countAlertsForID } from '../../common'; +import { createCaseError } from '../../common/error'; +import { CASE_SAVED_OBJECT } from '../../../common/constants'; +import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; +import { constructQueryOptions } from '../../routes/api/cases/helpers'; +import { defaultPage, defaultPerPage } from '../../routes/api'; +import { CasesClient } from '../client'; +import { update } from './update'; + +interface FindArgs { + caseID: string; + queryParams: SubCasesFindRequest; +} + +interface GetArgs { + includeComments: boolean; + id: string; +} + +/** + * The API routes for interacting with sub cases. + */ +export interface SubCasesClient { + delete(ids: string[]): Promise; + find(findArgs: FindArgs): Promise; + get(getArgs: GetArgs): Promise; + update(subCases: SubCasesPatchRequest): Promise; +} + +/** + * Creates a client for handling the different exposed API routes for interacting with sub cases. + */ +export function createSubCasesClient( + clientArgs: CasesClientArgs, + casesClient: CasesClient +): SubCasesClient { + return Object.freeze({ + delete: (ids: string[]) => deleteSubCase(ids, clientArgs), + find: (findArgs: FindArgs) => find(findArgs, clientArgs), + get: (getArgs: GetArgs) => get(getArgs, clientArgs), + update: (subCases: SubCasesPatchRequest) => update(subCases, clientArgs, casesClient), + }); +} + +async function deleteSubCase(ids: string[], clientArgs: CasesClientArgs): Promise { + try { + const { + savedObjectsClient: soClient, + user, + userActionService, + caseService, + attachmentService, + } = clientArgs; + + const [comments, subCases] = await Promise.all([ + caseService.getAllSubCaseComments({ soClient, id: ids }), + caseService.getSubCases({ soClient, ids }), + ]); + + const subCaseErrors = subCases.saved_objects.filter((subCase) => subCase.error !== undefined); + + if (subCaseErrors.length > 0) { + throw Boom.notFound( + `These sub cases ${subCaseErrors + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { + const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); + acc.set(subCase.id, parentID?.id); + return acc; + }, new Map()); + + await Promise.all( + comments.saved_objects.map((comment) => + attachmentService.delete({ soClient, attachmentId: comment.id }) + ) + ); + + await Promise.all(ids.map((id) => caseService.deleteSubCase(soClient, id))); + + const deleteDate = new Date().toISOString(); + + await userActionService.bulkCreate({ + soClient, + actions: ids.map((id) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action + // but we won't have the case ID + caseId: subCaseIDToParentID.get(id) ?? '', + subCaseId: id, + fields: ['sub_case', 'comment', 'status'], + }) + ), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete sub cases ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} + +async function find( + { caseID, queryParams }: FindArgs, + clientArgs: CasesClientArgs +): Promise { + try { + const { savedObjectsClient: soClient, caseService } = clientArgs; + + const ids = [caseID]; + const { subCase: subCaseQueryOptions } = constructQueryOptions({ + status: queryParams.status, + sortByField: queryParams.sortField, + }); + + const subCases = await caseService.findSubCasesGroupByCase({ + soClient, + ids, + options: { + sortField: 'created_at', + page: defaultPage, + perPage: defaultPerPage, + ...queryParams, + ...subCaseQueryOptions, + }, + }); + + const [open, inProgress, closed] = await Promise.all([ + ...caseStatuses.map((status) => { + const { subCase: statusQueryOptions } = constructQueryOptions({ + status, + sortByField: queryParams.sortField, + }); + return caseService.findSubCaseStatusStats({ + soClient, + options: statusQueryOptions ?? {}, + ids, + }); + }), + ]); + + return SubCasesFindResponseRt.encode( + transformSubCases({ + page: subCases.page, + perPage: subCases.perPage, + total: subCases.total, + subCasesMap: subCases.subCasesMap, + open, + inProgress, + closed, + }) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to find sub cases for case id: ${caseID}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} + +async function get( + { includeComments, id }: GetArgs, + clientArgs: CasesClientArgs +): Promise { + try { + const { savedObjectsClient: soClient, caseService } = clientArgs; + + const subCase = await caseService.getSubCase({ + soClient, + id, + }); + + if (!includeComments) { + return SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + }) + ); + } + + const theComments = await caseService.getAllSubCaseComments({ + soClient, + id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ + comments: theComments, + id, + }), + }) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to get sub case id: ${id}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/sub_cases/update.ts b/x-pack/plugins/cases/server/client/sub_cases/update.ts new file mode 100644 index 00000000000000..27e6e1261c0af5 --- /dev/null +++ b/x-pack/plugins/cases/server/client/sub_cases/update.ts @@ -0,0 +1,400 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { + SavedObjectsClientContract, + SavedObject, + SavedObjectsFindResponse, + Logger, +} from 'kibana/server'; + +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; +import { CasesClient } from '../../client'; +import { CaseService } from '../../services'; +import { + CaseStatuses, + SubCasesPatchRequest, + SubCasesPatchRequestRt, + CommentType, + excess, + throwErrors, + SubCasesResponse, + SubCasePatchRequest, + SubCaseAttributes, + ESCaseAttributes, + SubCaseResponse, + SubCasesResponseRt, + User, + CommentAttributes, +} from '../../../common/api'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; +import { + flattenSubCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, +} from '../../routes/api/utils'; +import { getCaseToUpdate } from '../../routes/api/cases/helpers'; +import { buildSubCaseUserActions } from '../../services/user_actions/helpers'; +import { createAlertUpdateRequest } from '../../common'; +import { createCaseError } from '../../common/error'; +import { UpdateAlertRequest } from '../../client/alerts/client'; +import { CasesClientArgs } from '../types'; + +function checkNonExistingOrConflict( + toUpdate: SubCasePatchRequest[], + fromStorage: Map> +) { + const nonExistingSubCases: SubCasePatchRequest[] = []; + const conflictedSubCases: SubCasePatchRequest[] = []; + for (const subCaseToUpdate of toUpdate) { + const bulkEntry = fromStorage.get(subCaseToUpdate.id); + + if (bulkEntry && bulkEntry.error) { + nonExistingSubCases.push(subCaseToUpdate); + } + + if (!bulkEntry || bulkEntry.version !== subCaseToUpdate.version) { + conflictedSubCases.push(subCaseToUpdate); + } + } + + if (nonExistingSubCases.length > 0) { + throw Boom.notFound( + `These sub cases ${nonExistingSubCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + if (conflictedSubCases.length > 0) { + throw Boom.conflict( + `These sub cases ${conflictedSubCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } +} + +interface GetParentIDsResult { + ids: string[]; + parentIDToSubID: Map; +} + +function getParentIDs({ + subCasesMap, + subCaseIDs, +}: { + subCasesMap: Map>; + subCaseIDs: string[]; +}): GetParentIDsResult { + return subCaseIDs.reduce( + (acc, id) => { + const subCase = subCasesMap.get(id); + if (subCase && subCase.references.length > 0) { + const parentID = subCase.references[0].id; + acc.ids.push(parentID); + let subIDs = acc.parentIDToSubID.get(parentID); + if (subIDs === undefined) { + subIDs = []; + } + subIDs.push(id); + acc.parentIDToSubID.set(parentID, subIDs); + } + return acc; + }, + { ids: [], parentIDToSubID: new Map() } + ); +} + +async function getParentCases({ + caseService, + soClient, + subCaseIDs, + subCasesMap, +}: { + caseService: CaseService; + soClient: SavedObjectsClientContract; + subCaseIDs: string[]; + subCasesMap: Map>; +}): Promise>> { + const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); + + const parentCases = await caseService.getCases({ + soClient, + caseIds: parentIDInfo.ids, + }); + + const parentCaseErrors = parentCases.saved_objects.filter((so) => so.error !== undefined); + + if (parentCaseErrors.length > 0) { + throw Boom.badRequest( + `Unable to find parent cases: ${parentCaseErrors + .map((c) => c.id) + .join(', ')} for sub cases: ${subCaseIDs.join(', ')}` + ); + } + + return parentCases.saved_objects.reduce((acc, so) => { + const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); + subCaseIDsWithParent?.forEach((subCaseId) => { + acc.set(subCaseId, so); + }); + return acc; + }, new Map>()); +} + +function getValidUpdateRequests( + toUpdate: SubCasePatchRequest[], + subCasesMap: Map> +): SubCasePatchRequest[] { + const validatedSubCaseAttributes: SubCasePatchRequest[] = toUpdate.map((updateCase) => { + const currentCase = subCasesMap.get(updateCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...updateCase, + }) + : { id: updateCase.id, version: updateCase.version }; + }); + + return validatedSubCaseAttributes.filter((updateCase: SubCasePatchRequest) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); +} + +/** + * Get the id from a reference in a comment for a sub case + */ +function getID(comment: SavedObject): string | undefined { + return comment.references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id; +} + +/** + * Get all the alert comments for a set of sub cases + */ +async function getAlertComments({ + subCasesToSync, + caseService, + soClient, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseService; + soClient: SavedObjectsClientContract; +}): Promise> { + const ids = subCasesToSync.map((subCase) => subCase.id); + return caseService.getAllSubCaseComments({ + soClient, + id: ids, + options: { + filter: nodeBuilder.or([ + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.generatedAlert), + ]), + }, + }); +} + +/** + * Updates the status of alerts for the specified sub cases. + */ +async function updateAlerts({ + caseService, + soClient, + casesClient, + logger, + subCasesToSync, +}: { + caseService: CaseService; + soClient: SavedObjectsClientContract; + casesClient: CasesClient; + logger: Logger; + subCasesToSync: SubCasePatchRequest[]; +}) { + try { + const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { + acc.set(subCase.id, subCase); + return acc; + }, new Map()); + // get all the alerts for all sub cases that need to be synced + const totalAlerts = await getAlertComments({ caseService, soClient, subCasesToSync }); + // create a map of the status (open, closed, etc) to alert info that needs to be updated + const alertsToUpdate = totalAlerts.saved_objects.reduce( + (acc: UpdateAlertRequest[], alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open + : CaseStatuses.open; + + acc.push(...createAlertUpdateRequest({ comment: alertComment.attributes, status })); + } + return acc; + }, + [] + ); + + await casesClient.casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); + } catch (error) { + throw createCaseError({ + message: `Failed to update alert status while updating sub cases: ${JSON.stringify( + subCasesToSync + )}: ${error}`, + logger, + error, + }); + } +} + +/** + * Handles updating the fields in a sub case. + */ +export async function update( + subCases: SubCasesPatchRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +): Promise { + const query = pipe( + excess(SubCasesPatchRequestRt).decode(subCases), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { savedObjectsClient: soClient, user, caseService, userActionService } = clientArgs; + + const bulkSubCases = await caseService.getSubCases({ + soClient, + ids: query.subCases.map((q) => q.id), + }); + + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + checkNonExistingOrConflict(query.subCases, subCasesMap); + + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); + + if (nonEmptySubCaseRequests.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } + + const subIDToParentCase = await getParentCases({ + soClient, + caseService, + subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), + subCasesMap, + }); + + const updatedAt = new Date().toISOString(); + const updatedCases = await caseService.patchSubCases({ + soClient, + subCases: nonEmptySubCaseRequests.map((thisCase) => { + const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; + let closedInfo: { closed_at: string | null; closed_by: User | null } = { + closed_at: null, + closed_by: null, + }; + + if ( + updateSubCaseAttributes.status && + updateSubCaseAttributes.status === CaseStatuses.closed + ) { + closedInfo = { + closed_at: updatedAt, + closed_by: user, + }; + } else if ( + updateSubCaseAttributes.status && + (updateSubCaseAttributes.status === CaseStatuses.open || + updateSubCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + subCaseId, + updatedAttributes: { + ...updateSubCaseAttributes, + ...closedInfo, + updated_at: updatedAt, + updated_by: user, + }, + version, + }; + }), + }); + + const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { + const storedSubCase = subCasesMap.get(subCaseToUpdate.id); + const parentCase = subIDToParentCase.get(subCaseToUpdate.id); + return ( + storedSubCase !== undefined && + subCaseToUpdate.status !== undefined && + storedSubCase.attributes.status !== subCaseToUpdate.status && + parentCase?.attributes.settings.syncAlerts + ); + }); + + await updateAlerts({ + caseService, + soClient, + casesClient, + subCasesToSync: subCasesToSyncAlertsFor, + logger: clientArgs.logger, + }); + + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( + (acc, updatedSO) => { + const originalSubCase = subCasesMap.get(updatedSO.id); + if (originalSubCase) { + acc.push( + flattenSubCaseSavedObject({ + savedObject: { + ...originalSubCase, + ...updatedSO, + attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, + references: originalSubCase.references, + version: updatedSO.version ?? originalSubCase.version, + }, + }) + ); + } + return acc; + }, + [] + ); + + await userActionService.bulkCreate({ + soClient, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, + actionBy: user, + }), + }); + + return SubCasesResponseRt.encode(returnUpdatedSubCases); + } catch (error) { + const idVersions = query.subCases.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + })); + throw createCaseError({ + message: `Failed to update sub cases: ${JSON.stringify(idVersions)}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts index 15eb5a421358b6..4f4870496f77ff 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -5,24 +5,12 @@ * 2.0. */ -import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { - SUB_CASES_PATCH_DEL_URL, - SAVED_OBJECT_TYPES, - CASE_SAVED_OBJECT, -} from '../../../../../common/constants'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; -export function initDeleteSubCasesApi({ - attachmentService, - caseService, - router, - userActionService, - logger, -}: RouteDeps) { +export function initDeleteSubCasesApi({ caseService, router, logger }: RouteDeps) { router.delete( { path: SUB_CASES_PATCH_DEL_URL, @@ -34,60 +22,8 @@ export function initDeleteSubCasesApi({ }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - - const [comments, subCases] = await Promise.all([ - caseService.getAllSubCaseComments({ soClient, id: request.query.ids }), - caseService.getSubCases({ soClient, ids: request.query.ids }), - ]); - - const subCaseErrors = subCases.saved_objects.filter( - (subCase) => subCase.error !== undefined - ); - - if (subCaseErrors.length > 0) { - throw Boom.notFound( - `These sub cases ${subCaseErrors - .map((c) => c.id) - .join(', ')} do not exist. Please check you have the correct ids.` - ); - } - - const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { - const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); - acc.set(subCase.id, parentID?.id); - return acc; - }, new Map()); - - await Promise.all( - comments.saved_objects.map((comment) => - attachmentService.delete({ soClient, attachmentId: comment.id }) - ) - ); - - await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(soClient, id))); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - await userActionService.bulkCreate({ - soClient, - actions: request.query.ids.map((id) => - buildCaseUserActionItem({ - action: 'delete', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action - // but we won't have the case ID - caseId: subCaseIDToParentID.get(id) ?? '', - subCaseId: id, - fields: ['sub_case', 'comment', 'status'], - }) - ), - }); + const client = await context.cases.getCasesClient(); + await client.subCases.delete(request.query.ids); return response.noContent(); } catch (error) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts index f9d077cbe3b122..80cfbbd6b584f8 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -12,17 +12,10 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - caseStatuses, - SubCasesFindRequestRt, - SubCasesFindResponseRt, - throwErrors, -} from '../../../../../common/api'; +import { SubCasesFindRequestRt, throwErrors } from '../../../../../common/api'; import { RouteDeps } from '../../types'; -import { escapeHatch, transformSubCases, wrapError } from '../../utils'; -import { SUB_CASES_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; -import { constructQueryOptions } from '../helpers'; -import { defaultPage, defaultPerPage } from '../..'; +import { escapeHatch, wrapError } from '../../utils'; +import { SUB_CASES_URL } from '../../../../../common/constants'; export function initFindSubCasesApi({ caseService, router, logger }: RouteDeps) { router.get( @@ -37,58 +30,17 @@ export function initFindSubCasesApi({ caseService, router, logger }: RouteDeps) }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); const queryParams = pipe( SubCasesFindRequestRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); - const ids = [request.params.case_id]; - const { subCase: subCaseQueryOptions } = constructQueryOptions({ - status: queryParams.status, - sortByField: queryParams.sortField, - }); - - const subCases = await caseService.findSubCasesGroupByCase({ - soClient, - ids, - options: { - sortField: 'created_at', - page: defaultPage, - perPage: defaultPerPage, - ...queryParams, - ...subCaseQueryOptions, - }, - }); - - const [open, inProgress, closed] = await Promise.all([ - ...caseStatuses.map((status) => { - const { subCase: statusQueryOptions } = constructQueryOptions({ - status, - sortByField: queryParams.sortField, - }); - return caseService.findSubCaseStatusStats({ - soClient, - options: statusQueryOptions ?? {}, - ids, - }); - }), - ]); - + const client = await context.cases.getCasesClient(); return response.ok({ - body: SubCasesFindResponseRt.encode( - transformSubCases({ - page: subCases.page, - perPage: subCases.perPage, - total: subCases.total, - subCasesMap: subCases.subCasesMap, - open, - inProgress, - closed, - }) - ), + body: await client.subCases.find({ + caseID: request.params.case_id, + queryParams, + }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts index afeaef639326d0..44ec5d68e9653f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts @@ -7,13 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { SubCaseResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; -import { flattenSubCaseSavedObject, wrapError } from '../../utils'; -import { SUB_CASE_DETAILS_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; -import { countAlertsForID } from '../../../../common'; +import { wrapError } from '../../utils'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; -export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { +export function initGetSubCaseApi({ router, logger }: RouteDeps) { router.get( { path: SUB_CASE_DETAILS_URL, @@ -29,47 +27,13 @@ export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - const includeComments = request.query.includeComments; - - const subCase = await caseService.getSubCase({ - soClient, - id: request.params.sub_case_id, - }); - - if (!includeComments) { - return response.ok({ - body: SubCaseResponseRt.encode( - flattenSubCaseSavedObject({ - savedObject: subCase, - }) - ), - }); - } - - const theComments = await caseService.getAllSubCaseComments({ - soClient, - id: request.params.sub_case_id, - options: { - sortField: 'created_at', - sortOrder: 'asc', - }, - }); + const client = await context.cases.getCasesClient(); return response.ok({ - body: SubCaseResponseRt.encode( - flattenSubCaseSavedObject({ - savedObject: subCase, - comments: theComments.saved_objects, - totalComment: theComments.total, - totalAlerts: countAlertsForID({ - comments: theComments, - id: request.params.sub_case_id, - }), - }) - ), + body: await client.subCases.get({ + id: request.params.sub_case_id, + includeComments: request.query.includeComments, + }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts index 4a407fc261a9b6..c1cd4b317da9bb 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -5,424 +5,12 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { - SavedObjectsClientContract, - KibanaRequest, - SavedObject, - SavedObjectsFindResponse, - Logger, -} from 'kibana/server'; - -import { nodeBuilder } from '../../../../../../../../src/plugins/data/common'; -import { CasesClient } from '../../../../client'; -import { CaseService, CaseUserActionService } from '../../../../services'; -import { - CaseStatuses, - SubCasesPatchRequest, - SubCasesPatchRequestRt, - CommentType, - excess, - throwErrors, - SubCasesResponse, - SubCasePatchRequest, - SubCaseAttributes, - ESCaseAttributes, - SubCaseResponse, - SubCasesResponseRt, - User, - CommentAttributes, -} from '../../../../../common/api'; -import { - SUB_CASES_PATCH_DEL_URL, - CASE_COMMENT_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, -} from '../../../../../common/constants'; +import { SubCasesPatchRequest } from '../../../../../common/api'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; -import { - escapeHatch, - flattenSubCaseSavedObject, - isCommentRequestTypeAlertOrGenAlert, - wrapError, -} from '../../utils'; -import { getCaseToUpdate } from '../helpers'; -import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; -import { createAlertUpdateRequest } from '../../../../common'; -import { createCaseError } from '../../../../common/error'; -import { UpdateAlertRequest } from '../../../../client/alerts/client'; - -interface UpdateArgs { - soClient: SavedObjectsClientContract; - caseService: CaseService; - userActionService: CaseUserActionService; - request: KibanaRequest; - casesClient: CasesClient; - subCases: SubCasesPatchRequest; - logger: Logger; -} - -function checkNonExistingOrConflict( - toUpdate: SubCasePatchRequest[], - fromStorage: Map> -) { - const nonExistingSubCases: SubCasePatchRequest[] = []; - const conflictedSubCases: SubCasePatchRequest[] = []; - for (const subCaseToUpdate of toUpdate) { - const bulkEntry = fromStorage.get(subCaseToUpdate.id); - - if (bulkEntry && bulkEntry.error) { - nonExistingSubCases.push(subCaseToUpdate); - } - - if (!bulkEntry || bulkEntry.version !== subCaseToUpdate.version) { - conflictedSubCases.push(subCaseToUpdate); - } - } - - if (nonExistingSubCases.length > 0) { - throw Boom.notFound( - `These sub cases ${nonExistingSubCases - .map((c) => c.id) - .join(', ')} do not exist. Please check you have the correct ids.` - ); - } - - if (conflictedSubCases.length > 0) { - throw Boom.conflict( - `These sub cases ${conflictedSubCases - .map((c) => c.id) - .join(', ')} has been updated. Please refresh before saving additional updates.` - ); - } -} - -interface GetParentIDsResult { - ids: string[]; - parentIDToSubID: Map; -} - -function getParentIDs({ - subCasesMap, - subCaseIDs, -}: { - subCasesMap: Map>; - subCaseIDs: string[]; -}): GetParentIDsResult { - return subCaseIDs.reduce( - (acc, id) => { - const subCase = subCasesMap.get(id); - if (subCase && subCase.references.length > 0) { - const parentID = subCase.references[0].id; - acc.ids.push(parentID); - let subIDs = acc.parentIDToSubID.get(parentID); - if (subIDs === undefined) { - subIDs = []; - } - subIDs.push(id); - acc.parentIDToSubID.set(parentID, subIDs); - } - return acc; - }, - { ids: [], parentIDToSubID: new Map() } - ); -} - -async function getParentCases({ - caseService, - soClient, - subCaseIDs, - subCasesMap, -}: { - caseService: CaseService; - soClient: SavedObjectsClientContract; - subCaseIDs: string[]; - subCasesMap: Map>; -}): Promise>> { - const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); - - const parentCases = await caseService.getCases({ - soClient, - caseIds: parentIDInfo.ids, - }); - - const parentCaseErrors = parentCases.saved_objects.filter((so) => so.error !== undefined); - - if (parentCaseErrors.length > 0) { - throw Boom.badRequest( - `Unable to find parent cases: ${parentCaseErrors - .map((c) => c.id) - .join(', ')} for sub cases: ${subCaseIDs.join(', ')}` - ); - } - - return parentCases.saved_objects.reduce((acc, so) => { - const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); - subCaseIDsWithParent?.forEach((subCaseId) => { - acc.set(subCaseId, so); - }); - return acc; - }, new Map>()); -} - -function getValidUpdateRequests( - toUpdate: SubCasePatchRequest[], - subCasesMap: Map> -): SubCasePatchRequest[] { - const validatedSubCaseAttributes: SubCasePatchRequest[] = toUpdate.map((updateCase) => { - const currentCase = subCasesMap.get(updateCase.id); - return currentCase != null - ? getCaseToUpdate(currentCase.attributes, { - ...updateCase, - }) - : { id: updateCase.id, version: updateCase.version }; - }); - - return validatedSubCaseAttributes.filter((updateCase: SubCasePatchRequest) => { - const { id, version, ...updateCaseAttributes } = updateCase; - return Object.keys(updateCaseAttributes).length > 0; - }); -} - -/** - * Get the id from a reference in a comment for a sub case - */ -function getID(comment: SavedObject): string | undefined { - return comment.references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id; -} +import { escapeHatch, wrapError } from '../../utils'; -/** - * Get all the alert comments for a set of sub cases - */ -async function getAlertComments({ - subCasesToSync, - caseService, - soClient, -}: { - subCasesToSync: SubCasePatchRequest[]; - caseService: CaseService; - soClient: SavedObjectsClientContract; -}): Promise> { - const ids = subCasesToSync.map((subCase) => subCase.id); - return caseService.getAllSubCaseComments({ - soClient, - id: ids, - options: { - filter: nodeBuilder.or([ - nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), - nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.generatedAlert), - ]), - }, - }); -} - -/** - * Updates the status of alerts for the specified sub cases. - */ -async function updateAlerts({ - caseService, - soClient, - casesClient, - logger, - subCasesToSync, -}: { - caseService: CaseService; - soClient: SavedObjectsClientContract; - casesClient: CasesClient; - logger: Logger; - subCasesToSync: SubCasePatchRequest[]; -}) { - try { - const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { - acc.set(subCase.id, subCase); - return acc; - }, new Map()); - // get all the alerts for all sub cases that need to be synced - const totalAlerts = await getAlertComments({ caseService, soClient, subCasesToSync }); - // create a map of the status (open, closed, etc) to alert info that needs to be updated - const alertsToUpdate = totalAlerts.saved_objects.reduce( - (acc: UpdateAlertRequest[], alertComment) => { - if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { - const id = getID(alertComment); - const status = - id !== undefined - ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open - : CaseStatuses.open; - - acc.push(...createAlertUpdateRequest({ comment: alertComment.attributes, status })); - } - return acc; - }, - [] - ); - - await casesClient.casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); - } catch (error) { - throw createCaseError({ - message: `Failed to update alert status while updating sub cases: ${JSON.stringify( - subCasesToSync - )}: ${error}`, - logger, - error, - }); - } -} - -async function update({ - soClient, - caseService, - userActionService, - request, - casesClient, - subCases, - logger, -}: UpdateArgs): Promise { - const query = pipe( - excess(SubCasesPatchRequestRt).decode(subCases), - fold(throwErrors(Boom.badRequest), identity) - ); - - try { - const bulkSubCases = await caseService.getSubCases({ - soClient, - ids: query.subCases.map((q) => q.id), - }); - - const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); - - checkNonExistingOrConflict(query.subCases, subCasesMap); - - const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); - - if (nonEmptySubCaseRequests.length <= 0) { - throw Boom.notAcceptable('All update fields are identical to current version.'); - } - - const subIDToParentCase = await getParentCases({ - soClient, - caseService, - subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), - subCasesMap, - }); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const updatedAt = new Date().toISOString(); - const updatedCases = await caseService.patchSubCases({ - soClient, - subCases: nonEmptySubCaseRequests.map((thisCase) => { - const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; - let closedInfo: { closed_at: string | null; closed_by: User | null } = { - closed_at: null, - closed_by: null, - }; - - if ( - updateSubCaseAttributes.status && - updateSubCaseAttributes.status === CaseStatuses.closed - ) { - closedInfo = { - closed_at: updatedAt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateSubCaseAttributes.status && - (updateSubCaseAttributes.status === CaseStatuses.open || - updateSubCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { - closed_at: null, - closed_by: null, - }; - } - return { - subCaseId, - updatedAttributes: { - ...updateSubCaseAttributes, - ...closedInfo, - updated_at: updatedAt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); - - const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { - const storedSubCase = subCasesMap.get(subCaseToUpdate.id); - const parentCase = subIDToParentCase.get(subCaseToUpdate.id); - return ( - storedSubCase !== undefined && - subCaseToUpdate.status !== undefined && - storedSubCase.attributes.status !== subCaseToUpdate.status && - parentCase?.attributes.settings.syncAlerts - ); - }); - - await updateAlerts({ - caseService, - soClient, - casesClient, - subCasesToSync: subCasesToSyncAlertsFor, - logger, - }); - - const returnUpdatedSubCases = updatedCases.saved_objects.reduce( - (acc, updatedSO) => { - const originalSubCase = subCasesMap.get(updatedSO.id); - if (originalSubCase) { - acc.push( - flattenSubCaseSavedObject({ - savedObject: { - ...originalSubCase, - ...updatedSO, - attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, - references: originalSubCase.references, - version: updatedSO.version ?? originalSubCase.version, - }, - }) - ); - } - return acc; - }, - [] - ); - - await userActionService.bulkCreate({ - soClient, - actions: buildSubCaseUserActions({ - originalSubCases: bulkSubCases.saved_objects, - updatedSubCases: updatedCases.saved_objects, - actionDate: updatedAt, - actionBy: { email, full_name, username }, - }), - }); - - return SubCasesResponseRt.encode(returnUpdatedSubCases); - } catch (error) { - const idVersions = query.subCases.map((subCase) => ({ - id: subCase.id, - version: subCase.version, - })); - throw createCaseError({ - message: `Failed to update sub cases: ${JSON.stringify(idVersions)}: ${error}`, - error, - logger, - }); - } -} - -export function initPatchSubCasesApi({ - router, - caseService, - userActionService, - logger, -}: RouteDeps) { +export function initPatchSubCasesApi({ router, caseService, logger }: RouteDeps) { router.patch( { path: SUB_CASES_PATCH_DEL_URL, @@ -434,17 +22,8 @@ export function initPatchSubCasesApi({ try { const casesClient = await context.cases.getCasesClient(); const subCases = request.body as SubCasesPatchRequest; - return response.ok({ - body: await update({ - request, - subCases, - casesClient, - soClient: context.core.savedObjects.client, - caseService, - userActionService, - logger, - }), + body: await casesClient.subCases.update(subCases), }); } catch (error) { logger.error(`Failed to patch sub cases in route: ${error}`);