diff --git a/.eslintrc.events.js b/.eslintrc.events.js new file mode 100644 index 0000000000..cc4b58754f --- /dev/null +++ b/.eslintrc.events.js @@ -0,0 +1,60 @@ +/* + * 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. + */ +module.exports = { + rules: { + '@typescript-eslint/await-thenable': 2, + '@typescript-eslint/no-empty-function': 'warn', + '@typescript-eslint/consistent-type-definitions': 1, + '@typescript-eslint/default-param-last': 2, + '@typescript-eslint/no-dynamic-delete': 2, + '@typescript-eslint/no-explicit-any': 1, + '@typescript-eslint/no-floating-promises': 2, + '@typescript-eslint/no-misused-promises': 2, + '@typescript-eslint/no-unnecessary-boolean-literal-compare': 2, + '@typescript-eslint/no-non-null-assertion': 2, + '@typescript-eslint/no-unnecessary-condition': 1, + '@typescript-eslint/no-unsafe-argument': 1, + '@typescript-eslint/no-unsafe-return': 1, + '@typescript-eslint/prefer-includes': 1, + '@typescript-eslint/promise-function-async': 2, + '@typescript-eslint/require-await': 2, + '@typescript-eslint/return-await': 2, + '@typescript-eslint/switch-exhaustiveness-check': 2, + 'func-style': [ + 'error', + 'declaration', + { + allowArrowFunctions: true + } + ], + 'no-restricted-syntax': [ + 'error', + { + selector: + ':matches(Program > VariableDeclaration) > VariableDeclarator > ArrowFunctionExpression', + message: 'Top-level arrow functions are not allowed.' + } + ], + 'no-shadow': 1, + 'no-undef-init': 2, + 'no-return-assign': 2, + 'vars-on-top': 1, + 'block-spacing': ['warn', 'always'], + curly: ['warn', 'all'], + 'no-nested-ternary': 'warn', + 'no-multiple-empty-lines': 1, + 'prefer-const': 1, + 'block-scoped-var': 1, + 'import/no-cycle': 1, + 'max-lines': ['warn', 600] + }, + parser: '@typescript-eslint/parser' +} diff --git a/packages/client/package.json b/packages/client/package.json index 4f3230b97a..798d2985dd 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest", "lint": "yarn lint:css && yarn lint:ts", "lint:css": "stylelint 'src/**/*.{ts,tsx}'", - "lint:ts": "eslint --fix './src/**/*.{ts,tsx}' --max-warnings=353", + "lint:ts": "eslint --fix './src/**/*.{ts,tsx}' --max-warnings=314", "test:compilation": "tsc --noEmit", "extract:translations": "bash extract-translations.sh", "generate-gateway-types": "NODE_OPTIONS=--dns-result-order=ipv4first graphql-codegen --config codegen.ts && prettier --write src/utils/gateway.ts", diff --git a/packages/client/src/v2-events/.eslintrc.js b/packages/client/src/v2-events/.eslintrc.js index a8fc18abd6..fefa86a0b6 100644 --- a/packages/client/src/v2-events/.eslintrc.js +++ b/packages/client/src/v2-events/.eslintrc.js @@ -9,52 +9,18 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ module.exports = { + extends: '../../../../.eslintrc.events.js', rules: { - '@typescript-eslint/await-thenable': 2, - '@typescript-eslint/no-empty-function': 'warn', - '@typescript-eslint/consistent-type-definitions': 1, - '@typescript-eslint/default-param-last': 2, - '@typescript-eslint/no-dynamic-delete': 2, - '@typescript-eslint/no-explicit-any': 1, - '@typescript-eslint/no-floating-promises': 2, - '@typescript-eslint/no-misused-promises': 2, - '@typescript-eslint/no-unnecessary-boolean-literal-compare': 2, - '@typescript-eslint/no-non-null-assertion': 2, - '@typescript-eslint/no-unnecessary-condition': 1, - '@typescript-eslint/no-unsafe-argument': 1, - '@typescript-eslint/no-unsafe-return': 1, - '@typescript-eslint/prefer-includes': 1, - '@typescript-eslint/promise-function-async': 2, - '@typescript-eslint/require-await': 2, - '@typescript-eslint/return-await': 2, - '@typescript-eslint/switch-exhaustiveness-check': 2, - 'func-style': [ - 'error', - 'declaration', - { - allowArrowFunctions: true - } - ], - 'no-restricted-syntax': [ - 'error', + 'react/jsx-no-literals': 1, + 'react/destructuring-assignment': 1, + 'react/jsx-sort-props': [ + 1, { - selector: - ':matches(Program > VariableDeclaration) > VariableDeclarator > ArrowFunctionExpression', - message: 'Top-level arrow functions are not allowed.' + reservedFirst: true, + callbacksLast: true, + shorthandFirst: true } ], - 'react/jsx-no-literals': 1, - 'no-shadow': 1, - 'no-undef-init': 2, - 'no-return-assign': 2, - 'vars-on-top': 1, - 'block-spacing': ['warn', 'always'], - curly: ['warn', 'all'], - 'no-nested-ternary': 'warn', - 'react/destructuring-assignment': 1, - 'no-multiple-empty-lines': 1, - 'prefer-const': 1, - 'block-scoped-var': 1, 'import/order': [ 'error', { @@ -73,16 +39,6 @@ module.exports = { pathGroupsExcludedImportTypes: ['builtin'] } ], - 'import/no-cycle': 1, - 'max-lines': ['warn', 600], - 'react/jsx-sort-props': [ - 1, - { - reservedFirst: true, - callbacksLast: true, - shorthandFirst: true - } - ], 'no-restricted-imports': [ 'error', { diff --git a/packages/client/src/v2-events/components/forms/utils.ts b/packages/client/src/v2-events/components/forms/utils.ts index 3e8ed5dcb1..85dc2adb70 100644 --- a/packages/client/src/v2-events/components/forms/utils.ts +++ b/packages/client/src/v2-events/components/forms/utils.ts @@ -73,7 +73,7 @@ export function evalExpressionInFieldDefinition( export function hasInitialValueDependencyInfo( value: BaseField['initialValue'] ): value is DependencyInfo { - return typeof value === 'object' && value !== null && 'dependsOn' in value + return typeof value === 'object' && 'dependsOn' in value } export function getDependentFields( diff --git a/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx b/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx index 73cf976c59..34b78fbe80 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Pages.tsx @@ -49,7 +49,7 @@ export function Pages() { ) const formPages = configuration.actions .find((action) => action.type === ActionType.DECLARE) - ?.forms.find((form) => form.active)?.pages + ?.forms.find((f) => f.active)?.pages if (!formPages) { throw new Error('Form configuration not found for type: ' + event.type) diff --git a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx index 619d93e748..c3421157f5 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx @@ -112,10 +112,6 @@ export function Review() { const { eventConfiguration: config } = useEventConfiguration(event.type) - if (!config) { - throw new Error('Event configuration not found with type: ' + event.type) - } - const { forms: formConfigs } = config.actions.filter( (action) => action.type === 'DECLARE' )[0] diff --git a/packages/client/src/v2-events/features/events/actions/register/Pages.tsx b/packages/client/src/v2-events/features/events/actions/register/Pages.tsx index b20c9a1190..4651b83046 100644 --- a/packages/client/src/v2-events/features/events/actions/register/Pages.tsx +++ b/packages/client/src/v2-events/features/events/actions/register/Pages.tsx @@ -49,7 +49,7 @@ export function Pages() { ) const formPages = configuration.actions .find((action) => action.type === ActionType.REGISTER) - ?.forms.find((form) => form.active)?.pages + ?.forms.find((f) => f.active)?.pages if (!formPages) { throw new Error('Form configuration not found for type: ' + event.type) diff --git a/packages/client/src/v2-events/features/events/actions/register/Review.tsx b/packages/client/src/v2-events/features/events/actions/register/Review.tsx index 10b80b24c4..f21961b0e8 100644 --- a/packages/client/src/v2-events/features/events/actions/register/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/register/Review.tsx @@ -59,10 +59,6 @@ export function Review() { const { eventConfiguration: config } = useEventConfiguration(event.type) - if (!config) { - throw new Error('Event configuration not found with type: ' + event.type) - } - const { forms: formConfigs } = config.actions.filter( (action) => action.type === ActionType.REGISTER )[0] diff --git a/packages/client/src/v2-events/features/events/fixtures.ts b/packages/client/src/v2-events/features/events/fixtures.ts index 9fedb20ee0..97c8e34eaf 100644 --- a/packages/client/src/v2-events/features/events/fixtures.ts +++ b/packages/client/src/v2-events/features/events/fixtures.ts @@ -8,7 +8,7 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { EventConfig, FormConfig } from '@opencrvs/commons/client' +import { EventConfigInput, FormConfig } from '@opencrvs/commons/client' export const DEFAULT_FORM = { label: { @@ -262,4 +262,4 @@ export const tennisClubMembershipEvent = { } } ] -} satisfies EventConfig +} satisfies EventConfigInput diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts index 6261c5dc31..e99ab9f980 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts @@ -33,6 +33,7 @@ function waitUntilEventIsCreated( const localVersion = utils.event.get.getData(eventId) if (!localVersion || localVersion.id === localVersion.transactionId) { + // eslint-disable-next-line no-console console.error( 'Event that has not been stored yet cannot be actioned upon' ) diff --git a/packages/commons/src/events/DeduplicationConfig.ts b/packages/commons/src/events/DeduplicationConfig.ts index 35b3d10765..371b98cc1e 100644 --- a/packages/commons/src/events/DeduplicationConfig.ts +++ b/packages/commons/src/events/DeduplicationConfig.ts @@ -70,41 +70,71 @@ const DateDistanceMatcher = Matcher.extend({ }) }) -export type And = { +export type AndInput = { type: 'and' - clauses: any[] + clauses: ClauseInput[] } -const And: z.ZodType = z.object({ - type: z.literal('and'), - clauses: z.lazy(() => Clause.array()) -}) +export type AndOutput = { + type: 'and' + clauses: ClauseOutput[] +} -export type Or = { +export type OrInput = { type: 'or' - clauses: any[] + clauses: ClauseInput[] } -const Or: z.ZodType = z.object({ +export type OrOutput = { + type: 'or' + clauses: ClauseOutput[] +} + +const And = z.object({ + type: z.literal('and'), + clauses: z.lazy(() => Clause.array()) +}) + +const Or = 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 type ClauseInput = + | AndInput + | OrInput + | z.input + | z.input + | z.input + | z.input + +export type ClauseOutput = + | AndOutput + | OrOutput + | z.output + | z.output + | z.output + | z.output + +/** + * Defines a deduplication clause. Clauses are either matcher clauses or logical clauses. Logical clauses (and, or) are used to combine multiple clauses. + * Since the definiton is recursive, we use z.lazy to define the schema. + * Zod supports recursive schemas, but needs help with Input and Output types. + * + * Default assumption is that the ZodType is the input. Markers use default values, so we need to explicitly define output type, too. + * + */ +export const Clause: z.ZodType = + z.lazy(() => + z.discriminatedUnion('type', [ + And, + Or, + FuzzyMatcher, + StrictMatcher, + DateRangeMatcher, + DateDistanceMatcher + ]) + ) export const DeduplicationConfig = z.object({ id: z.string(), diff --git a/packages/events/.eslintrc.js b/packages/events/.eslintrc.js index 5bcd96d91f..9049efcbcf 100644 --- a/packages/events/.eslintrc.js +++ b/packages/events/.eslintrc.js @@ -9,7 +9,7 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ module.exports = { - extends: '../../.eslintrc.js', + extends: '../../.eslintrc.events.js', env: { es6: true }, diff --git a/packages/events/package.json b/packages/events/package.json index 9e35ab4a26..4c39f7188d 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -46,7 +46,7 @@ "testcontainers": "^10.15.0", "ts-jest": "27.1.4", "ts-node": "^6.1.1", - "typescript": "4.9.5", + "typescript": "5.6.3", "vite-tsconfig-paths": "^3.5.0", "vitest": "^2.1.5" }, diff --git a/packages/events/src/router/event/index.ts b/packages/events/src/router/event/index.ts index f7fe39159e..c2e39ca773 100644 --- a/packages/events/src/router/event/index.ts +++ b/packages/events/src/router/event/index.ts @@ -34,13 +34,13 @@ import { } from '@opencrvs/commons/events' import { router, publicProcedure } from '@events/router/trpc' -const validateEventType = ({ +function validateEventType({ eventTypes, eventInputType }: { eventTypes: string[] eventInputType: string -}) => { +}) { if (!eventTypes.includes(eventInputType)) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -101,33 +101,39 @@ export const eventRouter = router({ return deleteEvent(input.eventId, { token: ctx.token }) }), actions: router({ - notify: publicProcedure.input(NotifyActionInput).mutation((options) => { - return addAction(options.input, { - eventId: options.input.eventId, - createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId, - token: options.ctx.token - }) - }), - declare: publicProcedure.input(DeclareActionInput).mutation((options) => { - return addAction(options.input, { - eventId: options.input.eventId, - createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId, - token: options.ctx.token - }) - }), - validate: publicProcedure.input(ValidateActionInput).mutation((options) => { - return addAction(options.input, { - eventId: options.input.eventId, - createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId, - token: options.ctx.token - }) - }), + notify: publicProcedure + .input(NotifyActionInput) + .mutation(async (options) => { + return addAction(options.input, { + eventId: options.input.eventId, + createdBy: options.ctx.user.id, + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token + }) + }), + declare: publicProcedure + .input(DeclareActionInput) + .mutation(async (options) => { + return addAction(options.input, { + eventId: options.input.eventId, + createdBy: options.ctx.user.id, + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token + }) + }), + validate: publicProcedure + .input(ValidateActionInput) + .mutation(async (options) => { + return addAction(options.input, { + eventId: options.input.eventId, + createdBy: options.ctx.user.id, + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token + }) + }), register: publicProcedure .input(RegisterActionInput.omit({ identifiers: true })) - .mutation((options) => { + .mutation(async (options) => { return addAction( { ...options.input, diff --git a/packages/events/src/router/middleware/middleware.ts b/packages/events/src/router/middleware/middleware.ts index 1fa15efea0..793c78f520 100644 --- a/packages/events/src/router/middleware/middleware.ts +++ b/packages/events/src/router/middleware/middleware.ts @@ -37,7 +37,7 @@ type MiddlewareOptions = Omit< * Depending on how the API is called, there might or might not be Bearer keyword in the header. * To allow for usage with both direct HTTP calls and TRPC, ensure it's present to be able to use shared scope auth functions. */ -const setBearerForToken = (token: string) => { +function setBearerForToken(token: string) { const bearer = 'Bearer' return token.startsWith(bearer) ? token : `${bearer} ${token}` @@ -46,14 +46,15 @@ const setBearerForToken = (token: string) => { * @param scopes scopes that are allowed to access the resource * @returns TRPC compatible middleware function */ -const createScopeAuthMiddleware = - (scopes: Scope[]) => (opts: MiddlewareOptions) => { +function createScopeAuthMiddleware(scopes: Scope[]) { + return async (opts: MiddlewareOptions) => { if (inScope({ Authorization: setBearerForToken(opts.ctx.token) }, scopes)) { return opts.next() } throw new TRPCError({ code: 'UNAUTHORIZED' }) } +} const isNationalSystemAdminUser = createScopeAuthMiddleware([ userScopes.nationalSystemAdmin diff --git a/packages/events/src/router/user/user.list.test.ts b/packages/events/src/router/user/user.list.test.ts index e68c033e1f..cc22ff808d 100644 --- a/packages/events/src/router/user/user.list.test.ts +++ b/packages/events/src/router/user/user.list.test.ts @@ -53,8 +53,8 @@ test('Returns multiple users', async () => { ) const userIds = [] - for (const user of usersToCreate) { - const userId = (await seed.user(userMgntDb, user))?.id + for (const userToCreate of usersToCreate) { + const userId = (await seed.user(userMgntDb, userToCreate)).id userIds.push(userId) } diff --git a/packages/events/src/service/deduplication/deduplication.test.ts b/packages/events/src/service/deduplication/deduplication.test.ts index 1e1afbf2ce..410ef96263 100644 --- a/packages/events/src/service/deduplication/deduplication.test.ts +++ b/packages/events/src/service/deduplication/deduplication.test.ts @@ -14,7 +14,7 @@ import { DeduplicationConfig, getUUID } from '@opencrvs/commons' import { searchForDuplicates } from './deduplication' import { getEventIndexName } from '@events/storage/__mocks__/elasticsearch' -const LEGACY_BIRTH_DEDUPLICATION_RULES: DeduplicationConfig = { +const LEGACY_BIRTH_DEDUPLICATION_RULES = { id: 'Legacy birth deduplication check', label: { id: 'deduplication.legacy', @@ -135,7 +135,7 @@ const LEGACY_BIRTH_DEDUPLICATION_RULES: DeduplicationConfig = { } export async function findDuplicates( - registrationComparison: Record + registrationComparison: Record ) { const esClient = getOrCreateClient() const existingComposition = Object.fromEntries( @@ -179,7 +179,7 @@ export async function findDuplicates( modifiedAt: '2025-01-01', updatedBy: 'test' }, - LEGACY_BIRTH_DEDUPLICATION_RULES + DeduplicationConfig.parse(LEGACY_BIRTH_DEDUPLICATION_RULES) ) return results @@ -255,7 +255,7 @@ describe('deduplication tests', () => { modifiedAt: '2025-01-01', updatedBy: 'test' }, - LEGACY_BIRTH_DEDUPLICATION_RULES + DeduplicationConfig.parse(LEGACY_BIRTH_DEDUPLICATION_RULES) ) expect(results).toHaveLength(0) }) diff --git a/packages/events/src/service/deduplication/deduplication.ts b/packages/events/src/service/deduplication/deduplication.ts index 3f563b7b64..1e7d5b9af4 100644 --- a/packages/events/src/service/deduplication/deduplication.ts +++ b/packages/events/src/service/deduplication/deduplication.ts @@ -14,103 +14,106 @@ import { getEventIndexName } from '@events/storage/elasticsearch' import * as elasticsearch from '@elastic/elasticsearch' -import { z } from 'zod' + import { EventIndex, DeduplicationConfig, - Clause + Clause, + ClauseOutput } from '@opencrvs/commons/events' import { subDays, addDays } from 'date-fns' -const dataReference = (fieldName: string) => `data.${fieldName}` +function dataReference(fieldName: string) { + return `data.${fieldName}` +} function generateElasticsearchQuery( eventIndex: EventIndex, - configuration: z.output + configuration: ClauseOutput ): 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) { + const matcherFieldWithoutData = + configuration.type !== 'and' && + configuration.type !== 'or' && + !eventIndex.data[configuration.fieldId] + + if (matcherFieldWithoutData) { return null } - if (configuration.type === 'fuzzy') { - return { - match: { - ['data.' + configuration.fieldId]: { - query: eventIndex.data[configuration.fieldId], - fuzziness: configuration.options.fuzziness, - boost: configuration.options.boost + switch (configuration.type) { + case 'and': + const clauses = configuration.clauses + .map((clause) => { + return generateElasticsearchQuery(eventIndex, clause) + }) + .filter( + (x): x is elasticsearch.estypes.QueryDslQueryContainer => x !== null + ) + + return { + bool: { + must: clauses + } as elasticsearch.estypes.QueryDslBoolQuery + } + case 'or': + return { + bool: { + should: configuration.clauses + .map((clause) => generateElasticsearchQuery(eventIndex, clause)) + .filter( + (x): x is elasticsearch.estypes.QueryDslQueryContainer => + x !== null + ) } } - } - } - - if (configuration.type === 'strict') { - return { - match_phrase: { - [dataReference(configuration.fieldId)]: - eventIndex.data[configuration.fieldId] || '' + case 'fuzzy': + return { + match: { + ['data.' + configuration.fieldId]: { + query: eventIndex.data[configuration.fieldId], + fuzziness: configuration.options.fuzziness, + boost: configuration.options.boost + } + } } - } - } - - 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() + case 'strict': + return { + match_phrase: { + [dataReference(configuration.fieldId)]: + eventIndex.data[configuration.fieldId] || '' } } - } - } - - if (configuration.type === 'dateDistance') { - return { - distance_feature: { - field: dataReference(configuration.fieldId), - pivot: `${configuration.options.days}d`, - origin: eventIndex.data[configuration.options.origin] + case 'dateRange': + return { + range: { + [dataReference(configuration.fieldId)]: { + // @TODO: Improve types for origin field to be sure it returns a string when accessing data + gte: subDays( + new Date(eventIndex.data[configuration.options.origin] as string), + configuration.options.days + ).toISOString(), + lte: addDays( + new Date(eventIndex.data[configuration.options.origin] as string), + configuration.options.days + ).toISOString() + } + } + } + case '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 -) { +): Promise<{ score: number; event: EventIndex | undefined }[]> { const esClient = getOrCreateClient() const query = Clause.parse(configuration.query) @@ -134,6 +137,6 @@ export async function searchForDuplicates( .filter((hit) => hit._source) .map((hit) => ({ score: hit._score || 0, - event: hit._source! + event: hit._source })) } diff --git a/packages/events/src/service/events/events.ts b/packages/events/src/service/events/events.ts index 2b7e63a60d..ff7a62784f 100644 --- a/packages/events/src/service/events/events.ts +++ b/packages/events/src/service/events/events.ts @@ -99,9 +99,9 @@ async function deleteEventAttachments(token: string, event: EventDocument) { const config = await getEventConfigurations(token) const form = config - .find((config) => config.id === event.type) + .find((c) => c.id === event.type) ?.actions.find((action) => action.type === event.type) - ?.forms.find((form) => form.active) + ?.forms.find((f) => f.active) const fieldTypes = form?.pages.flatMap((page) => page.fields) @@ -189,9 +189,9 @@ export async function addAction( const config = await getEventConfigurations(token) const form = config - .find((config) => config.id === event.type) + .find((c) => c.id === event.type) ?.actions.find((action) => action.type === input.type) - ?.forms.find((form) => form.active) + ?.forms.find((f) => f.active) const fieldTypes = form?.pages.flatMap((page) => page.fields) @@ -251,45 +251,42 @@ export async function validate( ) { const config = await getEventConfigurations(token) const storedEvent = await getEventById(eventId) - const form = config.find((config) => config.id === storedEvent.type) + const form = config.find((c) => c.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 futureEventState = getCurrentEventState({ + ...storedEvent, + actions: [ + ...storedEvent.actions, + { + ...input, + createdAt: new Date().toISOString(), + createdBy, + createdAtLocation + } + ] + }) + + const resultsFromAllRules = await Promise.all( + form.deduplication.map(async (deduplication) => { + const matches = await searchForDuplicates(futureEventState, deduplication) + return matches }) + ) - 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 - }) - } + duplicates = resultsFromAllRules + .flat() + .sort((a, b) => b.score - a.score) + .filter((hit): hit is { score: number; event: EventIndex } => !!hit.event) + .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) }, @@ -306,10 +303,6 @@ export async function validate( export async function patchEvent(eventInput: EventInputWithId) { const existingEvent = await getEventById(eventInput.id) - if (!existingEvent) { - throw new EventNotFoundError(eventInput.id) - } - const db = await events.getClient() const collection = db.collection('events') diff --git a/packages/events/src/service/files/index.ts b/packages/events/src/service/files/index.ts index d1e7a5598e..6a062a0f79 100644 --- a/packages/events/src/service/files/index.ts +++ b/packages/events/src/service/files/index.ts @@ -18,6 +18,7 @@ import { } from '@opencrvs/commons' import fetch from 'node-fetch' import { getEventConfigurations } from '@events/service/config/config' +import { z } from 'zod' function getFieldDefinitionForActionDataField( configuration: EventConfig, @@ -166,5 +167,7 @@ async function presignFiles( throw new Error('Failed to presign files') } - return res.json() + const fileUrls = z.array(z.string()).parse(await res.json()) + + return fileUrls } diff --git a/packages/events/src/service/indexing/indexing.ts b/packages/events/src/service/indexing/indexing.ts index 75a513267a..5ea6485a46 100644 --- a/packages/events/src/service/indexing/indexing.ts +++ b/packages/events/src/service/indexing/indexing.ts @@ -71,42 +71,34 @@ export async function createIndex( } function getElasticsearchMappingForType(field: FieldConfig) { - if (field.type === 'DATE') { - return { type: 'date' } - } - - if ( - field.type === 'TEXT' || - field.type === 'PARAGRAPH' || - field.type === 'BULLET_LIST' - ) { - return { type: 'text' } - } - - if ( - field.type === 'RADIO_GROUP' || - field.type === 'SELECT' || - field.type === 'COUNTRY' || - field.type === 'CHECKBOX' - ) { - return { type: 'keyword' } - } - - if (field.type === 'FILE') { - return { - type: 'object', - properties: { - filename: { type: 'keyword' }, - originalFilename: { type: 'keyword' }, - type: { type: 'keyword' } + switch (field.type) { + case 'DATE': + return { type: 'date' } + case 'TEXT': + case 'PARAGRAPH': + case 'BULLET_LIST': + return { type: 'text' } + case 'RADIO_GROUP': + case 'SELECT': + case 'COUNTRY': + case 'CHECKBOX': + return { type: 'keyword' } + case 'FILE': + return { + type: 'object', + properties: { + filename: { type: 'keyword' }, + originalFilename: { type: 'keyword' }, + type: { type: 'keyword' } + } } - } - } - return assertNever(field) + default: + assertNever(field) + } } -const assertNever = (n: never): never => { +function assertNever(_: never): never { throw new Error('Should never happen') } @@ -136,7 +128,7 @@ export async function indexAllEvents(eventConfiguration: EventConfig) { const transformedStreamData = new Transform({ readableObjectMode: true, writableObjectMode: true, - transform: (record, _encoding, callback) => { + transform: (record: EventDocument, _encoding, callback) => { callback(null, eventToEventIndex(record)) } }) diff --git a/packages/events/src/service/locations/locations.ts b/packages/events/src/service/locations/locations.ts index 24a1c9ebea..ba3358775b 100644 --- a/packages/events/src/service/locations/locations.ts +++ b/packages/events/src/service/locations/locations.ts @@ -33,7 +33,10 @@ export type Location = z.infer */ export async function setLocations(incomingLocations: Array) { const db = await events.getClient() - const currentLocations = await db.collection('locations').find().toArray() + const currentLocations = await db + .collection('locations') + .find() + .toArray() const [locationsToKeep, locationsToRemove] = _.partition( currentLocations, diff --git a/packages/events/src/storage/elasticsearch.ts b/packages/events/src/storage/elasticsearch.ts index 32f30f3829..49cc58f62c 100644 --- a/packages/events/src/storage/elasticsearch.ts +++ b/packages/events/src/storage/elasticsearch.ts @@ -11,7 +11,7 @@ import * as elasticsearch from '@elastic/elasticsearch' import { env } from '@events/environment' -let client: elasticsearch.Client +let client: elasticsearch.Client | undefined export const getOrCreateClient = () => { if (!client) { diff --git a/packages/events/src/storage/mongodb/__mocks__/events.ts b/packages/events/src/storage/mongodb/__mocks__/events.ts index c3ed65b2f1..bff35d2361 100644 --- a/packages/events/src/storage/mongodb/__mocks__/events.ts +++ b/packages/events/src/storage/mongodb/__mocks__/events.ts @@ -11,10 +11,10 @@ import { MongoClient } from 'mongodb' import { inject } from 'vitest' -let client: MongoClient +let client: MongoClient | undefined let databaseName = 'events_' + Date.now() -export async function resetServer() { +export function resetServer() { databaseName = 'events_' + Date.now() } diff --git a/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts b/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts index 9b449f05f3..637bfa2aa4 100644 --- a/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts +++ b/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts @@ -11,10 +11,10 @@ import { MongoClient } from 'mongodb' import { inject } from 'vitest' -let client: MongoClient +let client: MongoClient | undefined let databaseName = 'user-mgnt_' + Date.now() -export async function resetServer() { +export function resetServer() { databaseName = 'user-mgnt_' + Date.now() } diff --git a/packages/events/src/tests/generators.ts b/packages/events/src/tests/generators.ts index b0fe05cf98..a517d49f0e 100644 --- a/packages/events/src/tests/generators.ts +++ b/packages/events/src/tests/generators.ts @@ -19,20 +19,20 @@ import { import { Location } from '@events/service/locations/locations' import { Db } from 'mongodb' -type Name = { +interface Name { use: string given: string[] family: string } -export type CreatedUser = { +export interface CreatedUser { id: string primaryOfficeId: string systemRole: string name: Array } -type CreateUser = { +interface CreateUser { primaryOfficeId: string systemRole?: string name?: Array @@ -43,12 +43,12 @@ type CreateUser = { export function payloadGenerator() { const event = { create: (input: Partial = {}) => ({ - transactionId: input?.transactionId ?? getUUID(), - type: input?.type ?? 'TENNIS_CLUB_MEMBERSHIP' + transactionId: input.transactionId ?? getUUID(), + type: input.type ?? 'TENNIS_CLUB_MEMBERSHIP' }), patch: (id: string, input: Partial = {}) => ({ - transactionId: input?.transactionId ?? getUUID(), - type: input?.type ?? 'TENNIS_CLUB_MEMBERSHIP', + transactionId: input.transactionId ?? getUUID(), + type: input.type ?? 'TENNIS_CLUB_MEMBERSHIP', id }), actions: { @@ -57,8 +57,8 @@ export function payloadGenerator() { input: Partial> = {} ) => ({ type: ActionType.DECLARE, - transactionId: input?.transactionId ?? getUUID(), - data: input?.data ?? {}, + transactionId: input.transactionId ?? getUUID(), + data: input.data ?? {}, eventId }), validate: ( @@ -66,8 +66,8 @@ export function payloadGenerator() { input: Partial> = {} ) => ({ type: ActionType.VALIDATE, - transactionId: input?.transactionId ?? getUUID(), - data: input?.data ?? {}, + transactionId: input.transactionId ?? getUUID(), + data: input.data ?? {}, duplicates: [], eventId }) @@ -76,8 +76,8 @@ export function payloadGenerator() { const user = { create: (input: CreateUser) => ({ - systemRole: input?.systemRole ?? 'REGISTRATION_AGENT', - name: input?.name ?? [{ use: 'en', family: 'Doe', given: ['John'] }], + systemRole: input.systemRole ?? 'REGISTRATION_AGENT', + name: input.name ?? [{ use: 'en', family: 'Doe', given: ['John'] }], primaryOfficeId: input.primaryOfficeId }) } @@ -121,7 +121,7 @@ export function seeder() { id: createdUser.insertedId.toString() } } - const seedLocations = (db: Db, locations: Location[]) => + const seedLocations = async (db: Db, locations: Location[]) => db.collection('locations').insertMany(locations) return { diff --git a/packages/events/src/tests/global-setup.ts b/packages/events/src/tests/global-setup.ts index 666fcdcbe0..03c6a971a9 100644 --- a/packages/events/src/tests/global-setup.ts +++ b/packages/events/src/tests/global-setup.ts @@ -11,15 +11,12 @@ import { ElasticsearchContainer } from '@testcontainers/elasticsearch' import { MongoMemoryServer } from 'mongodb-memory-server' -export type { ProvidedContext } from 'vitest' +import type { ProvidedContext } from 'vitest' -declare module 'vitest' { - export interface ProvidedContext { - EVENTS_MONGO_URI: string - USER_MGNT_MONGO_URI: string - ELASTICSEARCH_URI: string - } -} +type ProvideFunction = ( + key: K, + value: ProvidedContext[K] +) => void async function setupServer() { return new ElasticsearchContainer('elasticsearch:8.14.3') @@ -33,7 +30,7 @@ async function setupServer() { .start() } -export default async function setup({ provide }: any) { +export default async function setup({ provide }: { provide: ProvideFunction }) { const eventsMongoD = await MongoMemoryServer.create() const userMgntMongoD = await MongoMemoryServer.create() const es = await setupServer() diff --git a/packages/events/src/tests/setup.ts b/packages/events/src/tests/setup.ts index 942b11175d..59120b2435 100644 --- a/packages/events/src/tests/setup.ts +++ b/packages/events/src/tests/setup.ts @@ -22,7 +22,7 @@ vi.mock('@events/storage/mongodb/user-mgnt') vi.mock('@events/storage/elasticsearch') vi.mock('@events/service/config/config', () => ({ - getEventConfigurations: () => + getEventConfigurations: async () => Promise.all([ tennisClubMembershipEvent, { ...tennisClubMembershipEvent, id: 'TENNIS_CLUB_MEMBERSHIP_PREMIUM' } @@ -40,7 +40,7 @@ async function resetESServer() { await createIndex(index, getAllFields(tennisClubMembershipEvent)) } -beforeEach(() => +beforeEach(async () => Promise.all([ resetEventsMongoServer(), resetUserMgntMongoServer(), diff --git a/packages/events/src/tests/utils.ts b/packages/events/src/tests/utils.ts index 35b256919f..eb62f7f75e 100644 --- a/packages/events/src/tests/utils.ts +++ b/packages/events/src/tests/utils.ts @@ -33,8 +33,8 @@ export function createTestClient(user: CreatedUser, scopes?: Scope[]) { return caller } -const createTestToken = (userId: string, scopes?: Scope[]) => - jwt.sign( +function createTestToken(userId: string, scopes?: Scope[]) { + return jwt.sign( { scope: scopes ?? userRoleScopes.REGISTRATION_AGENT, sub: userId }, readFileSync(join(__dirname, './cert.key')), { @@ -43,6 +43,7 @@ const createTestToken = (userId: string, scopes?: Scope[]) => audience: 'opencrvs:events-user' } ) +} /** * Setup for test cases. Creates a user and locations in the database, and provides relevant client instances and seeders. diff --git a/packages/events/src/tests/vitest.d.ts b/packages/events/src/tests/vitest.d.ts new file mode 100644 index 0000000000..34d8371a8c --- /dev/null +++ b/packages/events/src/tests/vitest.d.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export type { ProvidedContext } from 'vitest' + +declare module 'vitest' { + export interface ProvidedContext { + EVENTS_MONGO_URI: string + USER_MGNT_MONGO_URI: string + ELASTICSEARCH_URI: string + } +} diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json index 70ec3d00fb..fdabbd8f49 100644 --- a/packages/events/tsconfig.json +++ b/packages/events/tsconfig.json @@ -18,7 +18,8 @@ "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": true, - "types": ["vitest/globals"] + "types": ["vitest/globals"], + "esModuleInterop": true }, "include": ["src/**/*.ts", "typings", "tslint-rules"], "exclude": ["node_modules", "build", "scripts", "acceptance-tests"] diff --git a/yarn.lock b/yarn.lock index 2dc5a1aba2..a77ba37635 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23603,7 +23603,16 @@ 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", "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": + 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: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23763,7 +23772,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@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm: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== @@ -23784,6 +23793,13 @@ 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" @@ -24958,11 +24974,6 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript@4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== - typescript@5.6.3, "typescript@>=3 < 6", "typescript@^4.7 || 5", typescript@^5.6.3: version "5.6.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" @@ -26102,7 +26113,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@^7.0.0: +"wrap-ansi-cjs@npm: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== @@ -26129,6 +26140,15 @@ 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"