diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.test.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.test.ts index 67c848d0cef3a..20a7c717a24e2 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.test.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.test.ts @@ -93,6 +93,10 @@ const basicCase = { ], observables: [], incremental_id: undefined, + in_progress_at: undefined, + time_to_acknowledge: undefined, + time_to_investigate: undefined, + time_to_resolve: undefined, }; describe('RelatedCaseRt', () => { @@ -207,7 +211,10 @@ describe('CaseAttributesRt', () => { }, ], observables: [], - incremental_id: undefined, + in_progress_at: undefined, + time_to_acknowledge: undefined, + time_to_investigate: undefined, + time_to_resolve: undefined, }; it('has expected attributes in request', () => { diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts index c531ecaf32ef2..e7af63a8978ad 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts @@ -130,6 +130,10 @@ export const CaseAttributesRt = rt.intersection([ rt.exact( rt.partial({ incremental_id: rt.union([rt.number, rt.null]), + in_progress_at: rt.union([rt.string, rt.null]), + time_to_acknowledge: rt.union([rt.number, rt.null]), + time_to_investigate: rt.union([rt.number, rt.null]), + time_to_resolve: rt.union([rt.number, rt.null]), }) ), ]); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts index c9fcf245093ec..0c6c508a27b8d 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts @@ -32,6 +32,7 @@ describe('update', () => { }, ], }; + const casesClientMock = createCasesClientMock(); casesClientMock.configure.get = jest.fn().mockResolvedValue([]); @@ -1896,4 +1897,48 @@ describe('update', () => { }); }); }); + + describe('Metrics', () => { + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: mockCases }); + clientArgs.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + }); + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: mockCases, + }); + + clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue(new Map()); + }); + + it('calculates metrics correctly', async () => { + await bulkUpdate( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + status: CaseStatuses.closed, + }, + ], + }, + clientArgs, + casesClientMock + ); + + const updatedAttributes = + clientArgs.services.caseService.patchCases.mock.calls[0][0].cases[0].updatedAttributes; + + expect(updatedAttributes.time_to_acknowledge).toEqual(expect.any(Number)); + expect(updatedAttributes.time_to_investigate).toEqual(expect.any(Number)); + expect(updatedAttributes.time_to_resolve).toEqual(expect.any(Number)); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts index f6e965d484913..3a3d90af797f3 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts @@ -44,6 +44,8 @@ import { fillMissingCustomFields, getClosedInfoForUpdate, getDurationForUpdate, + getInProgressInfoForUpdate, + getTimingMetricsForUpdate, } from './utils'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import type { LicensingService } from '../../services/licensing'; @@ -656,6 +658,17 @@ const createPatchCasesPayload = ({ closedAt: updatedDt, createdAt: originalCase.attributes.created_at, }), + ...getInProgressInfoForUpdate({ + status: trimmedCaseAttributes.status, + stateTransitionTimestamp: updatedDt, + inProgressAt: originalCase.attributes.in_progress_at, + }), + ...getTimingMetricsForUpdate({ + status: trimmedCaseAttributes.status, + stateTransitionTimestamp: updatedDt, + createdAt: originalCase.attributes.created_at, + inProgressAt: originalCase.attributes.in_progress_at, + }), updated_at: updatedDt, updated_by: user, }, diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/push.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/push.ts index 6d4561c7b5119..d1a2215336295 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/push.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/push.ts @@ -31,7 +31,12 @@ import { OWNER_FIELD, } from '../../../common/constants'; -import { createIncident, getDurationInSeconds, getUserProfiles } from './utils'; +import { + createIncident, + getDurationInSeconds, + getTimingMetricsForUpdate, + getUserProfiles, +} from './utils'; import { createCaseError } from '../../common/error'; import { createAlertUpdateStatusRequest, @@ -230,6 +235,14 @@ export const push = async ( createdAt: theCase.created_at, }) : {}), + ...(shouldMarkAsClosed + ? getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + stateTransitionTimestamp: pushedDate, + createdAt: theCase.created_at, + inProgressAt: theCase.in_progress_at, + }) + : {}), external_service: externalService, updated_at: pushedDate, updated_by: { username, full_name, email, profile_uid }, diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts index f8e07522c46b8..4da6bcc3bd5df 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.test.ts @@ -31,6 +31,8 @@ import { addKibanaInformationToDescription, fillMissingCustomFields, normalizeCreateCaseRequest, + getInProgressInfoForUpdate, + getTimingMetricsForUpdate, } from './utils'; import type { CaseCustomFields, CustomFieldsConfiguration } from '../../../common/types/domain'; import { @@ -1073,6 +1075,349 @@ describe('utils', () => { }); }); + describe('getInProgressInfoForUpdate', () => { + const date = '2021-02-03T17:41:26.108Z'; + + it('returns the correct in_progress_at info when the case is marked as in-progress and the in_progress_at is not set', async () => { + expect( + getInProgressInfoForUpdate({ + status: CaseStatuses['in-progress'], + stateTransitionTimestamp: date, + }) + ).toEqual({ + in_progress_at: date, + }); + }); + + it('should not set the in_progress_at when the status is not in-progress', async () => { + expect( + getInProgressInfoForUpdate({ status: CaseStatuses.open, stateTransitionTimestamp: date }) + ).toBeUndefined(); + }); + + it('does not change the in_progress_at if it is already set', async () => { + expect( + getInProgressInfoForUpdate({ + status: CaseStatuses['in-progress'], + stateTransitionTimestamp: date, + inProgressAt: date, + }) + ).toBeUndefined(); + }); + + it('returns undefined if the status is not provided', async () => { + expect(getInProgressInfoForUpdate({ stateTransitionTimestamp: date })).toBeUndefined(); + }); + }); + + describe('getTimingMetricsForUpdate', () => { + const createdAt = '2021-11-23T19:00:00Z'; + const inProgressAt = '2021-11-23T19:00:10Z'; + const stateTransitionTimestamp = '2021-11-23T19:00:20Z'; + + describe('changing status to in-progress', () => { + it('should return the correct metrics', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + createdAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: 20, + time_to_investigate: null, + time_to_resolve: null, + }); + }); + + it('setting inProgressAt does not affect the metrics', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + createdAt, + inProgressAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: 20, + time_to_investigate: null, + time_to_resolve: null, + }); + }); + + it.each([['invalid'], [null]])( + 'returns undefined if the createdAt date is %s', + (createdAtInvalid) => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + // @ts-expect-error + createdAt: createdAtInvalid, + stateTransitionTimestamp, + }) + ).toBeUndefined(); + } + ); + + it.each([['invalid'], [null]])( + 'returns undefined if the stateTransitionTimestamp date is %s', + (stateTransitionTimestampInvalid) => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + createdAt, + // @ts-expect-error + stateTransitionTimestamp: stateTransitionTimestampInvalid, + }) + ).toBeUndefined(); + } + ); + + it('returns undefined if createdAt > stateTransitionTimestamp', async () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + createdAt: '2021-11-23T19:05:00Z', + stateTransitionTimestamp: '2021-11-23T19:00:00Z', + }) + ).toBeUndefined(); + }); + + it('rounds the seconds correctly', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + createdAt: '2022-04-11T15:56:00.087Z', + stateTransitionTimestamp: '2022-04-11T16:00:00.056Z', + }) + ).toEqual({ + time_to_acknowledge: 239, + time_to_investigate: null, + time_to_resolve: null, + }); + }); + + it('rounds the zero correctly', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses['in-progress'], + createdAt: '2022-04-11T15:56:00.087Z', + stateTransitionTimestamp: '2022-04-11T15:56:00.287Z', + }) + ).toEqual({ + time_to_acknowledge: 0, + time_to_investigate: null, + time_to_resolve: null, + }); + }); + }); + + describe('changing status to closed', () => { + it('should return the correct metrics when inProgressAt is not set', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: 20, + time_to_investigate: 0, + time_to_resolve: 20, + }); + }); + + it('should return the correct metrics when inProgressAt is set', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt, + inProgressAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: 10, + time_to_investigate: 10, + time_to_resolve: 20, + }); + }); + + it.each([['invalid'], [null]])( + 'returns undefined if the createdAt date is %s', + (createdAtInvalid) => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + // @ts-expect-error + createdAt: createdAtInvalid, + stateTransitionTimestamp, + }) + ).toBeUndefined(); + } + ); + + it.each([['invalid']])( + 'returns undefined if the inProgressAt date is %s', + (inProgressAtInvalid) => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt, + inProgressAt: inProgressAtInvalid, + stateTransitionTimestamp, + }) + ).toBeUndefined(); + } + ); + + it.each([['invalid'], [null]])( + 'returns undefined if the stateTransitionTimestamp date is %s', + (stateTransitionTimestampInvalid) => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt, + // @ts-expect-error + stateTransitionTimestamp: stateTransitionTimestampInvalid, + }) + ).toBeUndefined(); + } + ); + + it('returns undefined if createdAt > stateTransitionTimestamp', async () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt: '2021-11-23T19:05:00Z', + stateTransitionTimestamp: '2021-11-23T19:00:00Z', + }) + ).toBeUndefined(); + }); + + it('returns undefined if inProgressAt > stateTransitionTimestamp', async () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt: '2021-11-23T19:05:00Z', + inProgressAt: '2021-11-23T19:06:00Z', + stateTransitionTimestamp: '2021-11-23T19:00:00Z', + }) + ).toBeUndefined(); + }); + + it('returns undefined if inProgressAt > createdAt', async () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt: '2021-11-23T19:05:00Z', + inProgressAt: '2021-11-23T19:06:00Z', + stateTransitionTimestamp: '2021-11-23T19:00:00Z', + }) + ).toBeUndefined(); + }); + + it('rounds the seconds correctly', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt: '2022-04-11T15:56:00.087Z', + inProgressAt: '2022-04-11T15:58:00.043Z', + stateTransitionTimestamp: '2022-04-11T16:00:00.056Z', + }) + ).toEqual({ + time_to_acknowledge: 119, + time_to_investigate: 120, + time_to_resolve: 239, + }); + }); + + it('rounds the zero correctly', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt: '2022-04-11T15:56:00.087Z', + inProgressAt: '2022-04-11T15:56:00.187Z', + stateTransitionTimestamp: '2022-04-11T15:56:00.287Z', + }) + ).toEqual({ + time_to_acknowledge: 0, + time_to_investigate: 0, + time_to_resolve: 0, + }); + }); + }); + + describe('changing status to open', () => { + it('should return the correct metrics', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.open, + createdAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: null, + time_to_investigate: null, + time_to_resolve: null, + }); + }); + + it('setting inProgressAt does not affect the metrics', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.open, + createdAt, + inProgressAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: null, + time_to_investigate: null, + time_to_resolve: null, + }); + }); + }); + + it('should return the correct metrics when the case is marked as closed', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt, + inProgressAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: 10, + time_to_investigate: 10, + time_to_resolve: 20, + }); + }); + + it('should return the correct metrics when the case transitions from open to closed', () => { + expect( + getTimingMetricsForUpdate({ + status: CaseStatuses.closed, + createdAt, + stateTransitionTimestamp, + }) + ).toEqual({ + time_to_acknowledge: 20, + time_to_investigate: 0, + time_to_resolve: 20, + }); + }); + + it('returns undefined if the status is not provided', async () => { + expect( + getTimingMetricsForUpdate({ + createdAt, + inProgressAt, + stateTransitionTimestamp, + }) + ).toBe(undefined); + }); + }); + describe('getDurationForUpdate', () => { const createdAt = '2021-11-23T19:00:00Z'; const closedAt = '2021-11-23T19:02:00Z'; @@ -1124,7 +1469,7 @@ describe('utils', () => { } ); - it('returns undefined if created_at > closed_at', async () => { + it('returns undefined if createdAt > closedAt', async () => { expect( getDurationForUpdate({ status: CaseStatuses.closed, diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts index a7f596a0f9c9e..ef20c2876c9ec 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { uniqBy, isEmpty } from 'lodash'; +import { isEmpty, uniqBy } from 'lodash'; import type { UserProfile } from '@kbn/security-plugin/common'; import type { IBasePath } from '@kbn/core-http-browser'; import type { SecurityPluginStart } from '@kbn/security-plugin/server'; @@ -24,7 +24,7 @@ import type { ExternalService, User, } from '../../../common/types/domain'; -import { CaseStatuses, UserActionTypes, AttachmentType } from '../../../common/types/domain'; +import { AttachmentType, CaseStatuses, UserActionTypes } from '../../../common/types/domain'; import type { CasePostRequest, CaseRequestCustomFields, @@ -398,6 +398,145 @@ export const getClosedInfoForUpdate = ({ } }; +/** + * If the status changes to 'in-progress' and in_progress_at is not set, we set it to the current date. + * If the status does not change to 'in-progress' or in_progress_at is already set, we do not change it. + */ + +export const getInProgressInfoForUpdate = ({ + status, + stateTransitionTimestamp, + inProgressAt, +}: { + status?: CaseStatuses; + stateTransitionTimestamp: string; + inProgressAt?: string | null; +}): Partial> | undefined => { + if (status && status === CaseStatuses['in-progress'] && inProgressAt == null) { + return { + in_progress_at: stateTransitionTimestamp, + }; + } +}; + +const areValidDatesWhenChangingToInProgress = (createdAtMillis: number, updatedAtMillis: number) => + !isNaN(createdAtMillis) && !isNaN(updatedAtMillis) && updatedAtMillis >= createdAtMillis; + +const areValidDatesWhenClosing = ( + createdAtMillis: number, + stateTransitionTimestampMillis: number, + inProgressAtMillis: number | null +) => { + if ( + isNaN(createdAtMillis) || + isNaN(stateTransitionTimestampMillis) || + stateTransitionTimestampMillis < createdAtMillis + ) { + return false; + } + + if (inProgressAtMillis != null) { + return ( + !isNaN(inProgressAtMillis) && + inProgressAtMillis >= createdAtMillis && + stateTransitionTimestampMillis >= inProgressAtMillis + ); + } + + return true; +}; + +/** + * Calculates timing metrics based on the case status and timestamps. + * If the status is 'closed', it calculates all metrics. + * If the status is 'in-progress', it calculates only the time to acknowledge and sets the other metrics to null. + * If the status is 'open', it nullifies all metrics. + */ + +export const getTimingMetricsForUpdate = ({ + status, + createdAt, + inProgressAt, + stateTransitionTimestamp, +}: { + status?: CaseStatuses; + createdAt: string; + stateTransitionTimestamp: string; + inProgressAt?: string | null; +}): + | Partial> + | undefined => { + try { + const createdAtMillis = new Date(createdAt).getTime(); + const stateTransitionTimestampMillis = new Date(stateTransitionTimestamp).getTime(); + const inProgressAtMillis = inProgressAt ? new Date(inProgressAt).getTime() : null; + + if (status && status === CaseStatuses['in-progress']) { + if ( + createdAt != null && + stateTransitionTimestamp != null && + areValidDatesWhenChangingToInProgress(createdAtMillis, stateTransitionTimestampMillis) + ) { + return { + time_to_acknowledge: calculateTimeDifferenceInSeconds( + stateTransitionTimestampMillis, + createdAtMillis + ), + time_to_investigate: null, + time_to_resolve: null, + }; + } + } + + if (status && status === CaseStatuses.closed) { + if ( + createdAt != null && + stateTransitionTimestamp != null && + areValidDatesWhenClosing( + createdAtMillis, + stateTransitionTimestampMillis, + inProgressAtMillis + ) + ) { + const timeToResolve = calculateTimeDifferenceInSeconds( + stateTransitionTimestampMillis, + createdAtMillis + ); + + const timeToAcknowledge = + inProgressAtMillis != null + ? calculateTimeDifferenceInSeconds(inProgressAtMillis, createdAtMillis) + : timeToResolve; + + const timeToInvestigate = + inProgressAtMillis != null + ? calculateTimeDifferenceInSeconds(stateTransitionTimestampMillis, inProgressAtMillis) + : 0; + + return { + time_to_acknowledge: timeToAcknowledge, + time_to_investigate: timeToInvestigate, + time_to_resolve: timeToResolve, + }; + } + } + + if (status && status === CaseStatuses.open) { + // Reset all metrics when the status is re-opened + return { + time_to_acknowledge: null, + time_to_investigate: null, + time_to_resolve: null, + }; + } + } catch (err) { + // Silence date errors + } +}; + +const calculateTimeDifferenceInSeconds = (endTime: number, startTime: number) => + Math.floor((endTime - startTime) / 1000); + export const getDurationInSeconds = ({ closedAt, createdAt, diff --git a/x-pack/platform/plugins/shared/cases/server/common/types/case.ts b/x-pack/platform/plugins/shared/cases/server/common/types/case.ts index 2ea6b690e2881..e3840b4fc8bd5 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/types/case.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/types/case.ts @@ -33,6 +33,7 @@ export interface CasePersistedAttributes { closed_by: User | null; created_at: string; created_by: User; + in_progress_at?: string | null; connector: ConnectorPersisted; description: string; duration: number | null; @@ -51,6 +52,9 @@ export interface CasePersistedAttributes { customFields?: CasePersistedCustomFields; observables?: Observable[]; incremental_id?: number | null; + time_to_acknowledge?: number | null; + time_to_investigate?: number | null; + time_to_resolve?: number | null; } type CasePersistedCustomFields = Array<{ diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts index df92da97aa194..142d1f91e4208 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/index.ts @@ -10,3 +10,4 @@ export * from './latest'; export { casesSchema as casesSchemaV1 } from './v1'; export { casesSchema as casesSchemaV2 } from './v2'; export { casesSchema as casesSchemaV3 } from './v3'; +export { casesSchema as casesSchemaV4 } from './v4'; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts index 7d6bc9656d6f0..165b48adacae4 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './v3'; +export * from './v4'; diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v3.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v3.ts index 1b13300eedb01..6c841c365b007 100644 --- a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v3.ts +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v3.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; + import { casesSchema as casesSchemaV2 } from './v2'; export const casesSchema = casesSchemaV2.extends({ diff --git a/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v4.ts b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v4.ts new file mode 100644 index 0000000000000..297614b3987a1 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/saved_object_types/cases/schemas/v4.ts @@ -0,0 +1,17 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { casesSchema as casesSchemaV3 } from './v3'; + +export const casesSchema = casesSchemaV3.extends({ + in_progress_at: schema.maybe(schema.nullable(schema.string())), + time_to_acknowledge: schema.maybe(schema.nullable(schema.number())), + time_to_investigate: schema.maybe(schema.nullable(schema.number())), + time_to_resolve: schema.maybe(schema.nullable(schema.number())), +}); diff --git a/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts index 7e833cc037069..a46f77da40c00 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/cases/index.test.ts @@ -2211,7 +2211,11 @@ describe('CasesService', () => { 'category', 'customFields', 'observables', - 'incremental_id' + 'incremental_id', + 'in_progress_at', + 'time_to_acknowledge', + 'time_to_resolve', + 'time_to_investigate' ); describe('getCaseIdsByAlertId', () => { diff --git a/x-pack/test/cases_api_integration/common/lib/api/omit.ts b/x-pack/test/cases_api_integration/common/lib/api/omit.ts index 32aa68c44a462..7f42695610177 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/omit.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/omit.ts @@ -37,7 +37,11 @@ export const removeServerGeneratedPropertiesFromSavedObject = < }; export const removeServerGeneratedPropertiesFromCase = (theCase: Case): Partial => { - return removeServerGeneratedPropertiesFromSavedObject(theCase, ['closed_at']); + return removeServerGeneratedPropertiesFromSavedObject(theCase, [ + 'closed_at', + 'in_progress_at', + 'time_to_acknowledge', + ]); }; export const removeServerGeneratedPropertiesFromComments = ( diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts index 9502f426e295e..46471d40fa56f 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -140,10 +140,11 @@ export default ({ getService }: FtrProviderContext): void => { const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); const statusUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); - const { duration, ...dataWithoutDuration } = data; + // eslint-disable-next-line @typescript-eslint/naming-convention + const { duration, time_to_investigate, time_to_resolve, ...dataWithoutMetrics } = data; const { duration: resDuration, ...resWithoutDuration } = postCaseResp(); - expect(dataWithoutDuration).to.eql({ + expect(dataWithoutMetrics).to.eql({ ...resWithoutDuration, status: CaseStatuses.closed, closed_by: defaultUser, @@ -178,9 +179,11 @@ export default ({ getService }: FtrProviderContext): void => { const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); const statusUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); - const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { time_to_investigate, time_to_resolve, ...dataWithoutMetrics } = + removeServerGeneratedPropertiesFromCase(patchedCases[0]); - expect(data).to.eql({ + expect(dataWithoutMetrics).to.eql({ ...postCaseResp(), status: CaseStatuses['in-progress'], updated_by: defaultUser, @@ -197,6 +200,61 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('should set the in_progress_at when marking a case to in progress', async () => { + const originalCase = await createCase(supertest, postCaseReq); + + expect(originalCase.in_progress_at).to.eql(undefined); + + const [inProgressCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: originalCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + expect(inProgressCase.in_progress_at).to.be.a('string'); + }); + + it('should not reset in_progress_at when the case is reopened', async () => { + const originalCase = await createCase(supertest, postCaseReq); + + const [inProgressCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: originalCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const previousInProgressAt = inProgressCase.in_progress_at; + + const [reopenedCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: inProgressCase.version, + status: CaseStatuses.open, + }, + ], + }, + }); + + expect(reopenedCase.in_progress_at).to.equal(previousInProgressAt); + }); + it('should patch the severity of a case correctly', async () => { const postedCase = await createCase(supertest, postCaseReq); @@ -529,6 +587,110 @@ export default ({ getService }: FtrProviderContext): void => { } }); + describe('metrics', () => { + it('should set the metrics correctly when the case is marked as in-progress', async () => { + const originalCase = await createCase(supertest, postCaseReq); + + const [inProgressCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: originalCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + expect(inProgressCase.time_to_acknowledge).to.be.a('number'); + expect(inProgressCase.time_to_investigate).to.equal(null); + expect(inProgressCase.time_to_resolve).to.equal(null); + }); + + it('sets all metrics when the case is closed', async () => { + const originalCase = await createCase(supertest, postCaseReq); + + const [inProgressCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: originalCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const [closedCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: inProgressCase.id, + version: inProgressCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + expect(closedCase.time_to_investigate).to.be.a('number'); + expect(closedCase.time_to_resolve).to.be.a('number'); + expect(closedCase.time_to_acknowledge).to.be.a('number'); + }); + + it('should reset timing metrics when reopening a case', async () => { + const originalCase = await createCase(supertest, postCaseReq); + + const [inProgressCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: originalCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const [closedCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: inProgressCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + const [reopenedCase] = await updateCase({ + supertest, + params: { + cases: [ + { + id: originalCase.id, + version: closedCase.version, + status: CaseStatuses.open, + }, + ], + }, + }); + + expect(reopenedCase.time_to_acknowledge).to.equal(null); + expect(reopenedCase.time_to_investigate).to.equal(null); + expect(reopenedCase.time_to_resolve).to.equal(null); + }); + }); + it('should return the expected total comments and alerts', async () => { const postedCase = await createCase(supertest, postCaseReq);