diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index b09507251d..67be16ff58 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -12,7 +12,7 @@ "sourceMap": true, "moduleResolution": "node", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/client/src/v2-events/features/events/fixtures.ts b/packages/client/src/v2-events/features/events/fixtures.ts index dfb84a2730..9fedb20ee0 100644 --- a/packages/client/src/v2-events/features/events/fixtures.ts +++ b/packages/client/src/v2-events/features/events/fixtures.ts @@ -241,5 +241,25 @@ export const tennisClubMembershipEvent = { }, forms: [DEFAULT_FORM] } + ], + deduplication: [ + { + id: 'STANDARD CHECK', + label: { + defaultMessage: 'Standard check', + description: + 'This could be shown to the user in a reason for duplicate detected', + id: '...' + }, + query: { + type: 'and', + clauses: [ + { + fieldId: 'applicant.firstname', + type: 'strict' + } + ] + } + } ] } satisfies EventConfig diff --git a/packages/client/src/v2-events/features/events/useEventConfiguration.ts b/packages/client/src/v2-events/features/events/useEventConfiguration.ts index 9f6fe018a7..dfb6251bae 100644 --- a/packages/client/src/v2-events/features/events/useEventConfiguration.ts +++ b/packages/client/src/v2-events/features/events/useEventConfiguration.ts @@ -20,12 +20,6 @@ export function useEventConfigurations() { return config } -export function getAllFields(configuration: EventConfig) { - return configuration.actions - .flatMap((action) => action.forms.filter((form) => form.active)) - .flatMap((form) => form.pages.flatMap((page) => page.fields)) -} - /** * Fetches configured events and finds a matching event * @param eventIdentifier e.g. 'birth', 'death', 'marriage' or any configured event diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx index 9bfe834df5..e8dbe9ec61 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx @@ -14,6 +14,7 @@ import { useTypedParams } from 'react-router-typesafe-routes/dom' import { ActionDocument, EventIndex, + getAllFields, SummaryConfig } from '@opencrvs/commons/client' import { Content, ContentSize } from '@opencrvs/components/lib/Content' @@ -21,7 +22,6 @@ import { IconWithName } from '@client/v2-events/components/IconWithName' import { ROUTES } from '@client/v2-events/routes' import { - getAllFields, useEventConfiguration, useEventConfigurations } from '@client/v2-events/features/events/useEventConfiguration' diff --git a/packages/commons/src/events/ActionInput.ts b/packages/commons/src/events/ActionInput.ts index a1a3dd3cbc..17d6cdc0c6 100644 --- a/packages/commons/src/events/ActionInput.ts +++ b/packages/commons/src/events/ActionInput.ts @@ -9,8 +9,8 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { ActionType } from './ActionConfig' import { z } from 'zod' +import { ActionType } from './ActionConfig' import { FieldValue } from './FieldValue' const BaseActionInput = z.object({ @@ -39,7 +39,8 @@ export const RegisterActionInput = BaseActionInput.merge( export const ValidateActionInput = BaseActionInput.merge( z.object({ - type: z.literal(ActionType.VALIDATE).default(ActionType.VALIDATE) + type: z.literal(ActionType.VALIDATE).default(ActionType.VALIDATE), + duplicates: z.array(z.string()) }) ) diff --git a/packages/commons/src/events/DeduplicationConfig.ts b/packages/commons/src/events/DeduplicationConfig.ts new file mode 100644 index 0000000000..35b3d10765 --- /dev/null +++ b/packages/commons/src/events/DeduplicationConfig.ts @@ -0,0 +1,115 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import { z } from 'zod' +import { TranslationConfig } from './TranslationConfig' + +const FieldReference = z.string() + +const Matcher = z.object({ + fieldId: z.string(), + options: z + .object({ + boost: z.number().optional() + }) + .optional() + .default({}) +}) + +const FuzzyMatcher = Matcher.extend({ + type: z.literal('fuzzy'), + options: z + .object({ + /** + * Names of length 3 or less characters = 0 edits allowed + * Names of length 4 - 6 characters = 1 edit allowed + * Names of length >7 characters = 2 edits allowed + */ + fuzziness: z + .union([z.string(), z.number()]) + .optional() + .default('AUTO:4,7'), + boost: z.number().optional().default(1) + }) + .optional() + .default({}) +}) + +const StrictMatcher = Matcher.extend({ + type: z.literal('strict'), + options: z + .object({ + boost: z.number().optional().default(1) + }) + .optional() + .default({}) +}) + +const DateRangeMatcher = Matcher.extend({ + type: z.literal('dateRange'), + options: z.object({ + days: z.number(), + origin: FieldReference, + boost: z.number().optional().default(1) + }) +}) + +const DateDistanceMatcher = Matcher.extend({ + type: z.literal('dateDistance'), + options: z.object({ + days: z.number(), + origin: FieldReference, + boost: z.number().optional().default(1) + }) +}) + +export type And = { + type: 'and' + clauses: any[] +} + +const And: z.ZodType = z.object({ + type: z.literal('and'), + clauses: z.lazy(() => Clause.array()) +}) + +export type Or = { + type: 'or' + clauses: any[] +} + +const Or: z.ZodType = z.object({ + type: z.literal('or'), + clauses: z.lazy(() => Clause.array()) +}) + +export type Clause = + | And + | Or + | z.infer + | z.infer + | z.infer + +export const Clause = z.union([ + And, + Or, + FuzzyMatcher, + StrictMatcher, + DateRangeMatcher, + DateDistanceMatcher +]) + +export const DeduplicationConfig = z.object({ + id: z.string(), + label: TranslationConfig, + query: Clause +}) + +export type DeduplicationConfig = z.infer diff --git a/packages/commons/src/events/EventConfig.ts b/packages/commons/src/events/EventConfig.ts index 75f3cbfd25..ca13f78d0e 100644 --- a/packages/commons/src/events/EventConfig.ts +++ b/packages/commons/src/events/EventConfig.ts @@ -14,6 +14,7 @@ import { TranslationConfig } from './TranslationConfig' import { SummaryConfig, SummaryConfigInput } from './SummaryConfig' import { WorkqueueConfig } from './WorkqueueConfig' import { FormConfig, FormConfigInput } from './FormConfig' +import { DeduplicationConfig } from './DeduplicationConfig' /** * Description of event features defined by the country. Includes configuration for process steps and forms involved. @@ -29,7 +30,8 @@ export const EventConfig = z.object({ summary: SummaryConfig, label: TranslationConfig, actions: z.array(ActionConfig), - workqueues: z.array(WorkqueueConfig) + workqueues: z.array(WorkqueueConfig), + deduplication: z.array(DeduplicationConfig).optional().default([]) }) export const EventConfigInput = EventConfig.extend({ diff --git a/packages/commons/src/events/index.ts b/packages/commons/src/events/index.ts index ae6d21b7f2..70aa15aa1f 100644 --- a/packages/commons/src/events/index.ts +++ b/packages/commons/src/events/index.ts @@ -25,3 +25,4 @@ export * from './FieldValue' export * from './state' export * from './utils' export * from './defineConfig' +export * from './DeduplicationConfig' diff --git a/packages/commons/src/events/utils.ts b/packages/commons/src/events/utils.ts index 0e02cc43cc..6cedbeadde 100644 --- a/packages/commons/src/events/utils.ts +++ b/packages/commons/src/events/utils.ts @@ -13,7 +13,7 @@ import { TranslationConfig } from './TranslationConfig' import { EventMetadataKeys, eventMetadataLabelMap } from './EventMetadata' import { flattenDeep } from 'lodash' -import { EventConfigInput } from './EventConfig' +import { EventConfig, EventConfigInput } from './EventConfig' import { SummaryConfigInput } from './SummaryConfig' import { WorkqueueConfigInput } from './WorkqueueConfig' import { FieldType } from './FieldConfig' @@ -100,3 +100,9 @@ export const resolveFieldLabels = ({ }) } } + +export function getAllFields(configuration: EventConfig) { + return configuration.actions + .flatMap((action) => action.forms.filter((form) => form.active)) + .flatMap((form) => form.pages.flatMap((page) => page.fields)) +} diff --git a/packages/commons/tsconfig.esm.json b/packages/commons/tsconfig.esm.json index 6326cce88b..f542c66309 100644 --- a/packages/commons/tsconfig.esm.json +++ b/packages/commons/tsconfig.esm.json @@ -11,7 +11,7 @@ "moduleResolution": "node", "rootDir": "src", "declaration": true, - "lib": ["esnext.asynciterable", "es2015", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es2015", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/commons/tsconfig.json b/packages/commons/tsconfig.json index 242e9ba1d6..887dd84ee9 100644 --- a/packages/commons/tsconfig.json +++ b/packages/commons/tsconfig.json @@ -10,7 +10,7 @@ "sourceMap": true, "rootDir": "src", "declaration": true, - "lib": ["esnext.asynciterable", "es2015", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es2015", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index 160f2d6333..e17df6e460 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "lib/", "module": "ESNext", "target": "es6", - "lib": ["es6", "dom", "es2017"], + "lib": ["es6", "dom", "es2019"], "sourceMap": true, "allowJs": false, "declaration": true, diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json index 6f273f39f9..fc1a4e83b2 100644 --- a/packages/config/tsconfig.json +++ b/packages/config/tsconfig.json @@ -11,7 +11,7 @@ "sourceMap": true, "moduleResolution": "node16", "outDir": "build/dist", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/documents/tsconfig.json b/packages/documents/tsconfig.json index 66f852f5d9..507b7c99a4 100644 --- a/packages/documents/tsconfig.json +++ b/packages/documents/tsconfig.json @@ -10,7 +10,7 @@ "skipLibCheck": true, "moduleResolution": "node", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/events/package.json b/packages/events/package.json index 19a13e5ccf..9e35ab4a26 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -20,6 +20,7 @@ "@opencrvs/commons": "^1.7.0", "@trpc/server": "^11.0.0-rc.532", "app-module-path": "^2.2.0", + "date-fns": "^4.1.0", "envalid": "^8.0.0", "jsonwebtoken": "^9.0.0", "mongodb": "6.9.0", @@ -29,9 +30,9 @@ }, "devDependencies": { "@testcontainers/elasticsearch": "^10.15.0", + "@types/jsonwebtoken": "^9.0.0", "@typescript-eslint/eslint-plugin": "^4.5.0", "@typescript-eslint/parser": "^4.5.0", - "@types/jsonwebtoken": "^9.0.0", "cross-env": "^7.0.0", "eslint": "^7.11.0", "eslint-config-prettier": "^9.0.0", diff --git a/packages/events/src/router/router.ts b/packages/events/src/router/router.ts index 42318ab625..b3fccb60c4 100644 --- a/packages/events/src/router/router.ts +++ b/packages/events/src/router/router.ts @@ -21,7 +21,8 @@ import { deleteEvent, EventInputWithId, getEventById, - patchEvent + patchEvent, + validate } from '@events/service/events' import { presignFilesInEvent } from '@events/service/files' import { @@ -30,7 +31,10 @@ import { setLocations } from '@events/service/locations/locations' import { Context, middleware } from './middleware/middleware' -import { getIndexedEvents } from '@events/service/indexing/indexing' +import { + ensureIndexExists, + getIndexedEvents +} from '@events/service/indexing/indexing' import { EventConfig, getUUID } from '@opencrvs/commons' import { DeclareActionInput, @@ -78,14 +82,24 @@ export const appRouter = router({ }), event: router({ create: publicProcedure.input(EventInput).mutation(async (options) => { - const config = await getEventConfigurations(options.ctx.token) - const eventIds = config.map((c) => c.id) + const configurations = await getEventConfigurations(options.ctx.token) + const eventIds = configurations.map((c) => c.id) validateEventType({ eventTypes: eventIds, eventInputType: options.input.type }) + const configuration = configurations.find( + (c) => c.id === options.input.type + ) + + if (!configuration) { + throw new Error(`Event configuration not found ${options.input.type}`) + } + + await ensureIndexExists(options.input.type, configuration) + return createEvent({ eventInput: options.input, createdBy: options.ctx.user.id, @@ -136,9 +150,9 @@ export const appRouter = router({ }) }), validate: publicProcedure - .input(ValidateActionInput) + .input(ValidateActionInput.omit({ duplicates: true })) .mutation((options) => { - return addAction(options.input, { + return validate(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, createdAtLocation: options.ctx.user.primaryOfficeId, diff --git a/packages/events/src/service/deduplication/deduplication.test.ts b/packages/events/src/service/deduplication/deduplication.test.ts new file mode 100644 index 0000000000..1e1afbf2ce --- /dev/null +++ b/packages/events/src/service/deduplication/deduplication.test.ts @@ -0,0 +1,368 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { getOrCreateClient } from '@events/storage/elasticsearch' +import { DeduplicationConfig, getUUID } from '@opencrvs/commons' +import { searchForDuplicates } from './deduplication' +import { getEventIndexName } from '@events/storage/__mocks__/elasticsearch' + +const LEGACY_BIRTH_DEDUPLICATION_RULES: DeduplicationConfig = { + id: 'Legacy birth deduplication check', + label: { + id: 'deduplication.legacy', + defaultMessage: 'Legacy birth deduplication check', + description: 'Legacy birth deduplication check' + }, + query: { + type: 'or', + clauses: [ + { + type: 'and', + clauses: [ + { + type: 'strict', + fieldId: 'mother.identifier' + }, + { + type: 'fuzzy', + fieldId: 'mother.firstNames' + }, + { + type: 'fuzzy', + fieldId: 'mother.familyName' + }, + { + type: 'dateRange', + fieldId: 'mother.DoB', + options: { + days: 365, + origin: 'mother.DoB' + } + }, + { + type: 'and', + clauses: [ + { + type: 'dateRange', + fieldId: 'child.DoB', + options: { + days: 273, + origin: 'child.DoB' + } + }, + { + type: 'dateDistance', + fieldId: 'child.DoB', + options: { + days: 273, + origin: 'child.DoB' + } + } + ] + } + ] + }, + { + type: 'and', + clauses: [ + { + type: 'fuzzy', + fieldId: 'child.firstNames', + options: { + fuzziness: 1 + } + }, + { + type: 'fuzzy', + fieldId: 'child.familyName', + options: { + fuzziness: 1 + } + }, + { + type: 'strict', + fieldId: 'mother.identifier' + }, + { + type: 'fuzzy', + fieldId: 'mother.firstNames' + }, + { + type: 'fuzzy', + fieldId: 'mother.familyName' + }, + { + type: 'dateRange', + fieldId: 'mother.DoB', + options: { + days: 365, + origin: 'mother.DoB' + } + }, + { + type: 'or', + clauses: [ + { + type: 'dateRange', + fieldId: 'child.DoB', + options: { + days: 273, + origin: 'child.DoB' + } + }, + { + type: 'dateDistance', + fieldId: 'child.DoB', + options: { + days: 365, + origin: 'child.DoB' + } + } + ] + } + ] + } + ] + } +} + +export async function findDuplicates( + registrationComparison: Record +) { + const esClient = getOrCreateClient() + const existingComposition = Object.fromEntries( + Object.entries(registrationComparison).map(([key, values]) => [ + key, + values[0] + ]) + ) + const newComposition = Object.fromEntries( + Object.entries(registrationComparison).map(([key, values]) => [ + key, + values[1] + ]) + ) + + await esClient.update({ + index: getEventIndexName(), + id: getUUID(), + body: { + doc: { + id: getUUID(), + transactionId: getUUID(), + data: existingComposition + }, + doc_as_upsert: true + }, + refresh: 'wait_for' + }) + + const results = await searchForDuplicates( + { + data: newComposition, + // Random field values that should not affect the search + id: getUUID(), + type: 'birth', + status: 'CREATED', + createdAt: '2025-01-01', + createdBy: 'test', + createdAtLocation: 'test', + assignedTo: 'test', + modifiedAt: '2025-01-01', + updatedBy: 'test' + }, + LEGACY_BIRTH_DEDUPLICATION_RULES + ) + + return results +} + +describe('deduplication tests', () => { + it('does not find duplicates with completely different details', async () => { + await expect( + findDuplicates({ + // Similar child's firstname(s) + 'child.firstNames': ['John', 'Riku'], + // Similar child's lastname + 'child.familyName': ['Smith', 'Rouvila'], + // Date of birth within 5 days + 'child.DoB': ['2011-11-11', '2000-10-13'], + // Similar Mother’s firstname(s) + 'mother.firstNames': ['Mother', 'Sofia'], + // Similar Mother’s lastname. + 'mother.familyName': ['Smith', 'Matthews'], + // Similar Mother’s date of birth or Same Age of mother + 'mother.DoB': ['2000-11-11', '1980-09-02'], + // Same mother’s NID + 'mother.identifier': ['23412387', '8653434'] + }) + ).resolves.toStrictEqual([]) + }) + it('does not find duplicates with completely different details', async () => { + await expect( + findDuplicates({ + // Similar child's firstname(s) + 'child.firstNames': ['John', 'Riku'], + // Similar child's lastname + 'child.familyName': ['Smith', 'Rouvila'], + // Date of birth within 5 days + 'child.DoB': ['2011-11-11', '2000-10-13'], + // Similar Mother’s firstname(s) + 'mother.firstNames': ['Mother', 'Sofia'], + // Similar Mother’s lastname. + 'mother.familyName': ['Smith', 'Matthews'], + // Similar Mother’s date of birth or Same Age of mother + 'mother.DoB': ['2000-11-11', '1980-09-02'], + // Same mother’s NID + 'mother.identifier': ['23412387', '8653434'] + }) + ).resolves.toStrictEqual([]) + }) + it('does not find duplicates with the same id', async () => { + const esClient = getOrCreateClient() + + await esClient.update({ + index: getEventIndexName(), + id: getUUID(), + body: { + doc: { + id: '123-123-123-123' + }, + doc_as_upsert: true + }, + refresh: 'wait_for' + }) + + const results = await searchForDuplicates( + { + data: {}, + // Random field values that should not affect the search + id: '123-123-123-123', + type: 'birth', + status: 'CREATED', + createdAt: '2025-01-01', + createdBy: 'test', + createdAtLocation: 'test', + assignedTo: 'test', + modifiedAt: '2025-01-01', + updatedBy: 'test' + }, + LEGACY_BIRTH_DEDUPLICATION_RULES + ) + expect(results).toHaveLength(0) + }) + it('finds a duplicate with very similar details', async () => { + await expect( + findDuplicates({ + // Similar child's firstname(s) + 'child.firstNames': ['John', 'John'], + // Similar child's lastname + 'child.familyName': ['Smith', 'Smith'], + // Date of birth within 5 days + 'child.DoB': ['2011-11-11', '2011-11-11'], + // Similar Mother’s firstname(s) + 'mother.firstNames': ['Mother', 'Mothera'], + // Similar Mother’s lastname. + 'mother.familyName': ['Smith', 'Smith'], + // Similar Mother’s date of birth or Same Age of mother + 'mother.DoB': ['2000-11-11', '2000-11-12'], + // Same mother’s NID + 'mother.identifier': ['23412387', '23412387'] + }) + ).resolves.toHaveLength(1) + }) + + it('finds no duplicate with different mother nid', async () => { + await expect( + findDuplicates({ + 'child.firstNames': ['John', 'John'], + 'child.familyName': ['Smith', 'Smith'], + 'child.DoB': ['2011-11-11', '2011-11-11'], + 'mother.firstNames': ['Mother', 'Mother'], + 'mother.familyName': ['Smith', 'Smith'], + 'mother.DoB': ['2000-11-12', '2000-11-12'], + // Different mother’s NID + 'mother.identifier': ['23412387', '23412388'] + }) + ).resolves.toHaveLength(0) + }) + + it('finds no duplicates with very different details', async () => { + await expect( + findDuplicates({ + 'child.firstNames': ['John', 'Mathew'], + 'child.familyName': ['Smith', 'Wilson'], + 'child.DoB': ['2011-11-11', '1980-11-11'], + 'mother.firstNames': ['Mother', 'Harriet'], + 'mother.familyName': ['Smith', 'Wilson'], + 'mother.DoB': ['2000-11-12', '1992-11-12'], + 'mother.identifier': ['23412387', '123123'] + }) + ).resolves.toHaveLength(0) + }) + + it('finds a duplicate even if the firstName of child is not given', async () => { + await expect( + findDuplicates({ + 'child.firstNames': ['John', ''], + 'child.familyName': ['Smith', 'Smiht'], + 'child.DoB': ['2011-11-11', '2011-11-01'], + 'mother.firstNames': ['Mother', 'Mother'], + 'mother.familyName': ['Smith', 'Smith'], + 'mother.DoB': ['2000-11-12', '2000-11-12'] + }) + ).resolves.toHaveLength(1) + }) + + describe('same mother two births within 9 months of each other', () => { + it('finds a duplicate with same mother two births within 9 months', async () => { + await expect( + findDuplicates({ + 'child.firstNames': ['John', 'Janet'], + 'child.familyName': ['Smith', 'Smith'], + 'child.DoB': ['2011-11-11', '2011-12-01'], + 'mother.firstNames': ['Mother', 'Mother'], + 'mother.familyName': ['Smith', 'Smith'], + 'mother.DoB': ['2000-11-12', '2000-11-12'], + 'mother.identifier': ['23412387', '23412387'] + }) + ).resolves.toHaveLength(1) + }) + + it('births more than 9 months apart', async () => { + await expect( + findDuplicates({ + 'child.firstNames': ['John', 'Janet'], + 'child.familyName': ['Smith', 'Smith'], + 'child.DoB': ['2011-11-11', '2010-10-01'], + 'mother.firstNames': ['Mother', 'Mother'], + 'mother.familyName': ['Smith', 'Smith'], + 'mother.DoB': ['2000-11-12', '2000-11-12'], + 'mother.identifier': ['23412387', '23412387'] + }) + ).resolves.toStrictEqual([]) + }) + }) + + it('finds a duplicate for even there is a 3 year gap', async () => { + await expect( + findDuplicates({ + 'child.DoB': ['2011-11-11', '2014-11-01'], + 'child.firstNames': ['John', 'John'], + 'child.familyName': ['Smith', 'Smith'], + 'mother.firstNames': ['Mother', 'Mother'], + 'mother.familyName': ['Smith', 'Smith'], + 'mother.DoB': ['2000-11-12', '2000-11-12'], + 'mother.identifier': ['23412387', '23412387'] + }) + ).resolves.toHaveLength(1) + }) +}) diff --git a/packages/events/src/service/deduplication/deduplication.ts b/packages/events/src/service/deduplication/deduplication.ts new file mode 100644 index 0000000000..3f563b7b64 --- /dev/null +++ b/packages/events/src/service/deduplication/deduplication.ts @@ -0,0 +1,139 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { + getOrCreateClient, + getEventIndexName +} from '@events/storage/elasticsearch' +import * as elasticsearch from '@elastic/elasticsearch' +import { z } from 'zod' +import { + EventIndex, + DeduplicationConfig, + Clause +} from '@opencrvs/commons/events' +import { subDays, addDays } from 'date-fns' + +const dataReference = (fieldName: string) => `data.${fieldName}` + +function generateElasticsearchQuery( + eventIndex: EventIndex, + configuration: z.output +): elasticsearch.estypes.QueryDslQueryContainer | null { + if (configuration.type === 'and') { + const clauses = configuration.clauses + .map((clause) => generateElasticsearchQuery(eventIndex, clause)) + .filter( + (x): x is elasticsearch.estypes.QueryDslQueryContainer => x !== null + ) + return { + bool: { + must: clauses + } as elasticsearch.estypes.QueryDslBoolQuery + } + } + if (configuration.type === 'or') { + return { + bool: { + should: configuration.clauses + .map((clause) => generateElasticsearchQuery(eventIndex, clause)) + .filter( + (x): x is elasticsearch.estypes.QueryDslQueryContainer => x !== null + ) + } + } + } + const truthyFormDataValue = eventIndex.data[configuration.fieldId] + if (!truthyFormDataValue) { + return null + } + + if (configuration.type === 'fuzzy') { + return { + match: { + ['data.' + configuration.fieldId]: { + query: eventIndex.data[configuration.fieldId], + fuzziness: configuration.options.fuzziness, + boost: configuration.options.boost + } + } + } + } + + if (configuration.type === 'strict') { + return { + match_phrase: { + [dataReference(configuration.fieldId)]: + eventIndex.data[configuration.fieldId] || '' + } + } + } + + if (configuration.type === 'dateRange') { + return { + range: { + [dataReference(configuration.fieldId)]: { + gte: subDays( + new Date(eventIndex.data[configuration.options.origin]), + configuration.options.days + ).toISOString(), + lte: addDays( + new Date(eventIndex.data[configuration.options.origin]), + configuration.options.days + ).toISOString() + } + } + } + } + + if (configuration.type === 'dateDistance') { + return { + distance_feature: { + field: dataReference(configuration.fieldId), + pivot: `${configuration.options.days}d`, + origin: eventIndex.data[configuration.options.origin] + } + } + } + + throw new Error(`Unknown clause type`) +} + +export async function searchForDuplicates( + eventIndex: EventIndex, + configuration: DeduplicationConfig +) { + const esClient = getOrCreateClient() + const query = Clause.parse(configuration.query) + + const esQuery = generateElasticsearchQuery(eventIndex, query) + + if (!esQuery) { + return [] + } + + const result = await esClient.search({ + index: getEventIndexName('TENNIS_CLUB_MEMBERSHIP'), + query: { + bool: { + should: [esQuery], + must_not: [{ term: { id: eventIndex.id } }] + } + } + }) + + return result.hits.hits + .filter((hit) => hit._source) + .map((hit) => ({ + score: hit._score || 0, + event: hit._source! + })) +} diff --git a/packages/events/src/service/events.ts b/packages/events/src/service/events.ts index 5a29ac7884..effd9c6122 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events.ts @@ -12,8 +12,10 @@ import { ActionInputWithType, EventDocument, + EventIndex, EventInput, FileFieldValue, + getCurrentEventState, isUndeclaredDraft } from '@opencrvs/commons/events' @@ -25,6 +27,7 @@ import * as _ from 'lodash' import { TRPCError } from '@trpc/server' import { getEventConfigurations } from './config/config' import { deleteFile, fileExists } from './files' +import { searchForDuplicates } from './deduplication/deduplication' export const EventInputWithId = EventInput.extend({ id: z.string() @@ -87,7 +90,7 @@ export async function deleteEvent( const { id } = event await collection.deleteOne({ id }) - await deleteEventIndex(id) + await deleteEventIndex(event) return { id } } @@ -231,6 +234,74 @@ export async function addAction( return updatedEvent } +export async function validate( + input: Omit, 'duplicates'>, + { + eventId, + createdBy, + token, + createdAtLocation + }: { + eventId: string + createdBy: string + createdAtLocation: string + token: string + } +) { + const config = await getEventConfigurations(token) + const storedEvent = await getEventById(eventId) + const form = config.find((config) => config.id === storedEvent.type) + + if (!form) { + throw new Error(`Form not found with event type: ${storedEvent.type}`) + } + let duplicates: EventIndex[] = [] + + if (form.deduplication) { + const futureEventState = getCurrentEventState({ + ...storedEvent, + actions: [ + ...storedEvent.actions, + { + ...input, + createdAt: new Date().toISOString(), + createdBy, + createdAtLocation + } + ] + }) + + const resultsFromAllRules = await Promise.all( + form.deduplication.map(async (deduplication) => { + const duplicates = await searchForDuplicates( + futureEventState, + deduplication + ) + return duplicates + }) + ) + + duplicates = resultsFromAllRules + .flat() + .sort((a, b) => b.score - a.score) + .map((hit) => hit.event) + .filter((event, index, self) => { + return self.findIndex((t) => t.id === event.id) === index + }) + } + + const event = await addAction( + { ...input, duplicates: duplicates.map((d) => d.id) }, + { + eventId, + createdBy, + token, + createdAtLocation + } + ) + return event +} + export async function patchEvent(eventInput: EventInputWithId) { const existingEvent = await getEventById(eventInput.id) diff --git a/packages/events/src/service/indexing/indexing.test.ts b/packages/events/src/service/indexing/indexing.test.ts index 20d7622cce..d82177d58e 100644 --- a/packages/events/src/service/indexing/indexing.test.ts +++ b/packages/events/src/service/indexing/indexing.test.ts @@ -16,6 +16,7 @@ import { import { payloadGenerator } from '@events/tests/generators' import { createTestClient } from '@events/tests/utils' import { indexAllEvents } from './indexing' +import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' const client = createTestClient() const generator = payloadGenerator() @@ -23,14 +24,14 @@ const generator = payloadGenerator() test('indexes all records from MongoDB with one function call', async () => { const esClient = getOrCreateClient() - await indexAllEvents() + await indexAllEvents(tennisClubMembershipEvent) for (let i = 0; i < 2; i++) { await client.event.create(generator.event.create()) } const body = await esClient.search({ - index: getEventIndexName(), + index: getEventIndexName('TENNIS_CLUB_MEMBERSHIP'), body: { query: { match_all: {} @@ -46,7 +47,7 @@ test('records are automatically indexed when they are created', async () => { const esClient = getOrCreateClient() const body = await esClient.search({ - index: getEventIndexName(), + index: getEventIndexName('TENNIS_CLUB_MEMBERSHIP'), body: { query: { match_all: {} diff --git a/packages/events/src/service/indexing/indexing.ts b/packages/events/src/service/indexing/indexing.ts index 5af143a3d8..08b1cffd09 100644 --- a/packages/events/src/service/indexing/indexing.ts +++ b/packages/events/src/service/indexing/indexing.ts @@ -10,17 +10,22 @@ */ import { + EventConfig, EventDocument, EventIndex, + FieldConfig, + getAllFields, getCurrentEventState } from '@opencrvs/commons/events' import { type estypes } from '@elastic/elasticsearch' import { getClient } from '@events/storage' import { + getEventAliasName, getEventIndexName, getOrCreateClient } from '@events/storage/elasticsearch' +import { logger } from '@opencrvs/commons' import { Transform } from 'stream' import { z } from 'zod' @@ -33,9 +38,13 @@ function eventToEventIndex(event: EventDocument): EventIndex { */ type EventIndexMapping = { [key in keyof EventIndex]: estypes.MappingProperty } -export function createIndex(indexName: string) { +export async function createIndex( + indexName: string, + formFields: FieldConfig[] +) { const client = getOrCreateClient() - return client.indices.create({ + + await client.indices.create({ index: indexName, body: { mappings: { @@ -49,25 +58,73 @@ export function createIndex(indexName: string) { modifiedAt: { type: 'date' }, assignedTo: { type: 'keyword' }, updatedBy: { type: 'keyword' }, - data: { type: 'object', enabled: true } + data: { + type: 'object', + properties: formFieldsToDataMapping(formFields) + } } satisfies EventIndexMapping } } }) + return client.indices.putAlias({ + index: indexName, + name: getEventAliasName() + }) +} + +function getElasticsearchMappingForType(field: FieldConfig) { + if (field.type === 'DATE') { + return { type: 'date' } + } + + if (field.type === 'TEXT' || field.type === 'PARAGRAPH') { + return { type: 'text' } + } + + if (field.type === 'RADIO_GROUP') { + return { type: 'keyword' } + } + + if (field.type === 'FILE') { + return { + type: 'object', + properties: { + filename: { type: 'keyword' }, + originalFilename: { type: 'keyword' }, + type: { type: 'keyword' } + } + } + } + + return assertNever(field) } -export async function indexAllEvents() { +const assertNever = (n: never): never => { + throw new Error('Should never happen') +} + +function formFieldsToDataMapping(fields: FieldConfig[]) { + return fields.reduce((acc, field) => { + return { + ...acc, + [field.id]: getElasticsearchMappingForType(field) + } + }, {}) +} + +export async function indexAllEvents(eventConfiguration: EventConfig) { const mongoClient = await getClient() const esClient = getOrCreateClient() + const indexName = getEventIndexName(eventConfiguration.id) const hasEventsIndex = await esClient.indices.exists({ - index: getEventIndexName() + index: indexName }) if (!hasEventsIndex) { - await createIndex(getEventIndexName()) + await createIndex(indexName, getAllFields(eventConfiguration)) } - const stream = mongoClient.collection(getEventIndexName()).find().stream() + const stream = mongoClient.collection(indexName).find().stream() const transformedStreamData = new Transform({ readableObjectMode: true, @@ -83,7 +140,7 @@ export async function indexAllEvents() { datasource: stream.pipe(transformedStreamData), onDocument: (doc: EventIndex) => ({ index: { - _index: getEventIndexName(), + _index: indexName, _id: doc.id } }), @@ -93,9 +150,10 @@ export async function indexAllEvents() { export async function indexEvent(event: EventDocument) { const esClient = getOrCreateClient() + const indexName = getEventIndexName(event.type) - return esClient.update({ - index: getEventIndexName(), + return esClient.update({ + index: indexName, id: event.id, body: { doc: eventToEventIndex(event), @@ -105,12 +163,28 @@ export async function indexEvent(event: EventDocument) { }) } -export async function deleteEventIndex(eventId: string) { +export async function ensureIndexExists( + eventType: string, + configuration: EventConfig +) { + const esClient = getOrCreateClient() + const indexName = getEventIndexName(eventType) + const hasEventsIndex = await esClient.indices.exists({ + index: indexName + }) + + if (!hasEventsIndex) { + logger.error(`Events index ${indexName} does not exist. Creating one.`) + await createIndex(indexName, getAllFields(configuration)) + } +} + +export async function deleteEventIndex(event: EventDocument) { const esClient = getOrCreateClient() const response = await esClient.delete({ - index: getEventIndexName(), - id: eventId, + index: getEventIndexName(event.type), + id: event.id, refresh: 'wait_for' }) @@ -121,20 +195,15 @@ export async function getIndexedEvents() { const esClient = getOrCreateClient() const hasEventsIndex = await esClient.indices.exists({ - index: getEventIndexName() + index: getEventAliasName() }) if (!hasEventsIndex) { - // @TODO: We probably want to create the index on startup or as part of the deployment process. - // eslint-disable-next-line no-console - console.error('Events index does not exist. Creating one.') - await createIndex(getEventIndexName()) - return [] } const response = await esClient.search({ - index: getEventIndexName(), + index: getEventAliasName(), size: 10000, request_cache: false }) diff --git a/packages/events/src/storage/__mocks__/elasticsearch.ts b/packages/events/src/storage/__mocks__/elasticsearch.ts index 9ebbaa1c6c..9a394f5640 100644 --- a/packages/events/src/storage/__mocks__/elasticsearch.ts +++ b/packages/events/src/storage/__mocks__/elasticsearch.ts @@ -12,6 +12,7 @@ import * as elasticsearch from '@elastic/elasticsearch' import { inject, vi } from 'vitest' export const getEventIndexName = vi.fn() +export const getEventAliasName = vi.fn() export function getOrCreateClient() { return new elasticsearch.Client({ diff --git a/packages/events/src/storage/elasticsearch.ts b/packages/events/src/storage/elasticsearch.ts index b3f0ad44df..e177d759ac 100644 --- a/packages/events/src/storage/elasticsearch.ts +++ b/packages/events/src/storage/elasticsearch.ts @@ -13,8 +13,6 @@ import { env } from '@events/environment' let client: elasticsearch.Client -export const getEventIndexName = () => 'events' - export const getOrCreateClient = () => { if (!client) { client = new elasticsearch.Client({ @@ -25,3 +23,11 @@ export const getOrCreateClient = () => { return client } + +export function getEventAliasName() { + return `events` +} + +export function getEventIndexName(eventType: string) { + return `events_${eventType}`.toLowerCase() +} diff --git a/packages/events/src/tests/setup.ts b/packages/events/src/tests/setup.ts index a79d60a86d..3cde916374 100644 --- a/packages/events/src/tests/setup.ts +++ b/packages/events/src/tests/setup.ts @@ -14,6 +14,7 @@ import { inject, vi } from 'vitest' import { createIndex } from '@events/service/indexing/indexing' import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' import { mswServer } from './msw' +import { getAllFields } from '@opencrvs/commons' vi.mock('@events/storage/mongodb') vi.mock('@events/storage/elasticsearch') @@ -26,11 +27,14 @@ vi.mock('@events/service/config/config', () => ({ })) async function resetServer() { - // @ts-ignore "Cannot find module '@events/storage/elasticsearch' or its corresponding type declarations." - const { getEventIndexName } = await import('@events/storage/elasticsearch') - const index = 'events' + Date.now() + Math.random() + const { getEventIndexName, getEventAliasName } = await import( + // @ts-ignore "Cannot find module '@events/storage/elasticsearch' or its corresponding type declarations." + '@events/storage/elasticsearch' + ) + const index = 'events_tennis_club_membership' + Date.now() + Math.random() getEventIndexName.mockReturnValue(index) - await createIndex(index) + getEventAliasName.mockReturnValue('events_' + +Date.now() + Math.random()) + await createIndex(index, getAllFields(tennisClubMembershipEvent)) } beforeEach(async () => { diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json index 241cda5dd4..70ec3d00fb 100644 --- a/packages/events/tsconfig.json +++ b/packages/events/tsconfig.json @@ -11,7 +11,7 @@ "skipLibCheck": true, "moduleResolution": "node16", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/metrics/tsconfig.json b/packages/metrics/tsconfig.json index ed19c3dc2b..04501dc28c 100644 --- a/packages/metrics/tsconfig.json +++ b/packages/metrics/tsconfig.json @@ -12,7 +12,7 @@ "skipLibCheck": true, "moduleResolution": "node16", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/notification/tsconfig.json b/packages/notification/tsconfig.json index 287b2a2fc4..3c39281d7c 100644 --- a/packages/notification/tsconfig.json +++ b/packages/notification/tsconfig.json @@ -10,7 +10,7 @@ "sourceMap": true, "moduleResolution": "node16", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json index 0fe1033ee4..a6f480672b 100644 --- a/packages/search/tsconfig.json +++ b/packages/search/tsconfig.json @@ -11,7 +11,7 @@ "sourceMap": true, "moduleResolution": "node16", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 05ac70d02f..142c2ec91e 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/toolkit", - "version": "0.0.9-events", + "version": "0.0.13-events", "description": "OpenCRVS toolkit for building country configurations", "license": "MPL-2.0", "exports": { diff --git a/packages/toolkit/src/conditionals/deduplication.ts b/packages/toolkit/src/conditionals/deduplication.ts new file mode 100644 index 0000000000..a15424befd --- /dev/null +++ b/packages/toolkit/src/conditionals/deduplication.ts @@ -0,0 +1,78 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import { Clause } from '@opencrvs/commons/events' + +export function and(clauses: Clause[]): Clause { + return { + type: 'and', + clauses + } +} + +export function or(clauses: Clause[]): Clause { + return { + type: 'or', + clauses + } +} + +export function field(fieldId: string) { + return { + fuzzyMatches: ( + options: { fuzziness?: string | number; boost?: number } = {} + ) => + ({ + fieldId, + type: 'fuzzy', + options: { + fuzziness: options.fuzziness ?? 'AUTO:4,7', + boost: options.boost ?? 1 + } + } as const), + strictMatches: (options: { boost?: number } = {}) => + ({ + fieldId, + type: 'strict', + options: { + boost: options.boost ?? 1 + } + } as const), + dateRangeMatches: (options: { + days: number + origin: string + boost?: number + }) => + ({ + fieldId, + type: 'dateRange', + options: { + days: options.days, + origin: options.origin, + boost: options.boost ?? 1 + } + } as const), + dateDistanceMatches: (options: { + days: number + origin: string + boost?: number + }) => + ({ + fieldId, + type: 'dateDistance', + options: { + days: options.days, + origin: options.origin, + boost: options.boost ?? 1 + } + } as const) + } +} diff --git a/packages/toolkit/src/conditionals/index.ts b/packages/toolkit/src/conditionals/index.ts index c6173ecaf0..1065e48695 100644 --- a/packages/toolkit/src/conditionals/index.ts +++ b/packages/toolkit/src/conditionals/index.ts @@ -11,6 +11,8 @@ import { JSONSchema } from '@opencrvs/commons/conditionals' import { ActionDocument } from '@opencrvs/commons/events' +export * as deduplication from './deduplication' + export function defineConditional(conditional: JSONSchema): JSONSchema { return conditional } diff --git a/packages/webhooks/tsconfig.json b/packages/webhooks/tsconfig.json index e0fe6589e6..9462ff7f59 100644 --- a/packages/webhooks/tsconfig.json +++ b/packages/webhooks/tsconfig.json @@ -12,7 +12,7 @@ "sourceMap": true, "moduleResolution": "node16", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/packages/workflow/tsconfig.json b/packages/workflow/tsconfig.json index ceef8c9334..18fc70f1a1 100644 --- a/packages/workflow/tsconfig.json +++ b/packages/workflow/tsconfig.json @@ -11,7 +11,7 @@ "sourceMap": true, "moduleResolution": "node16", "rootDir": ".", - "lib": ["esnext.asynciterable", "es6", "es2017"], + "lib": ["esnext.asynciterable", "es6", "es2019"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, diff --git a/yarn.lock b/yarn.lock index 7d25e58123..2dc5a1aba2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12946,6 +12946,11 @@ date-fns@^2.28.0, date-fns@^2.29.3, date-fns@^2.30.0: dependencies: "@babel/runtime" "^7.21.0" +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== + dateformat@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" @@ -23598,16 +23603,7 @@ string-similarity@^4.0.1: resolved "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz" integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23767,7 +23763,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -23788,13 +23784,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" @@ -26113,7 +26102,7 @@ workbox-window@7.1.0, workbox-window@^7.1.0: "@types/trusted-types" "^2.0.2" workbox-core "7.1.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -26140,15 +26129,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"