diff --git a/CHANGELOG.md b/CHANGELOG.md index b81f3d4c0df..fb28665b715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ - Fix the informant column on the Perfomance page showing "Other family member" when `Someone else` is selected for a registration [#6157](https://github.com/opencrvs/opencrvs-core/issues/6157) - Fix the event name displayed in email templates for death correction requests [#7703](https://github.com/opencrvs/opencrvs-core/issues/7703) +## 1.6.3 Release candidate + +### Improvements + +- For countries where local phone numbers start with 0, we now ensure the prefix remains unchanged when converting to and from the international format. + ## 1.6.2 Release candidate ### Bug fixes diff --git a/docker-compose.yml b/docker-compose.yml index edf2a079392..bde1b195a48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,12 +89,12 @@ services: depends_on: - base environment: - - MONGO_URL=mongodb://mongo1/events - - ES_HOST=elasticsearch:9200 - - COUNTRY_CONFIG_URL=http://countryconfig:3040/ + - EVENTS_MONGO_URL=mongodb://mongo1/events + - USER_MGNT_MONGO_URL=mongodb://mongo1/user-mgnt + - ES_URL=http://elasticsearch:9200 + - COUNTRY_CONFIG_URL=http://countryconfig:3040 - DOCUMENTS_URL=http://documents:9050 - - USER_MANAGEMENT_URL=http://localhost:3030/ - + - USER_MANAGEMENT_URL=http://user-mgnt:3030 # User facing services workflow: image: opencrvs/ocrvs-workflow:${VERSION} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index b09507251d6..67be16ff585 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/forms/register/mappings/query/registration-mappings.test.ts b/packages/client/src/forms/register/mappings/query/registration-mappings.test.ts index d2c5293f400..e9153a3ee9c 100644 --- a/packages/client/src/forms/register/mappings/query/registration-mappings.test.ts +++ b/packages/client/src/forms/register/mappings/query/registration-mappings.test.ts @@ -16,5 +16,10 @@ describe('phone number conversion from international format back to local format expect(convertToLocal('+260211000000', 'ZMB')).toBe('0211000000') expect(convertToLocal('+358504700715', 'FIN')).toBe('0504700715') expect(convertToLocal('+237666666666', 'CMR')).toBe('666666666') + expect(convertToLocal('+8801700000000', 'BGD')).toBe('01700000000') + expect(convertToLocal('+260211000000')).toBe('0211000000') + expect(convertToLocal('+358504700715')).toBe('0504700715') + expect(convertToLocal('+237666666666')).toBe('666666666') + expect(convertToLocal('+8801700000000')).toBe('01700000000') }) }) diff --git a/packages/client/src/forms/register/mappings/query/registration-mappings.ts b/packages/client/src/forms/register/mappings/query/registration-mappings.ts index c17758fbaa0..3c3f2de432b 100644 --- a/packages/client/src/forms/register/mappings/query/registration-mappings.ts +++ b/packages/client/src/forms/register/mappings/query/registration-mappings.ts @@ -97,28 +97,36 @@ export const certificateDateTransformer = export const convertToLocal = ( mobileWithCountryCode: string, - alpha3CountryCode: string + alpha3CountryCode?: string ) => { /* * If country is the fictional demo country (Farajaland), use Zambian number format */ - - const countryCode = countryAlpha3toAlpha2(alpha3CountryCode) + const phoneUtil = PhoneNumberUtil.getInstance() + const number = phoneUtil.parse(mobileWithCountryCode) + const countryCode = alpha3CountryCode + ? countryAlpha3toAlpha2(alpha3CountryCode) + : phoneUtil.getRegionCodeForNumber(number) if (!countryCode) { return } - const phoneUtil = PhoneNumberUtil.getInstance() - if (!phoneUtil.isPossibleNumberString(mobileWithCountryCode, countryCode)) { return } - const number = phoneUtil.parse(mobileWithCountryCode, countryCode) - - return phoneUtil + let nationalFormat = phoneUtil .format(number, PhoneNumberFormat.NATIONAL) .replace(/[^A-Z0-9]+/gi, '') + + // This is a special case for countries that have a national prefix of 0 + if ( + phoneUtil.getNddPrefixForRegion(countryCode, true) === '0' && + !nationalFormat.startsWith('0') + ) { + nationalFormat = '0' + nationalFormat + } + return nationalFormat } export const localPhoneTransformer = @@ -131,7 +139,9 @@ export const localPhoneTransformer = ) => { const fieldName = transformedFieldName || field.name const msisdnPhone = get(queryData, fieldName as string) as unknown as string - + if (!msisdnPhone) { + return transformedData + } const localPhone = convertToLocal(msisdnPhone, window.config.COUNTRY) transformedData[sectionId][field.name] = localPhone diff --git a/packages/client/src/offline/selectors.ts b/packages/client/src/offline/selectors.ts index 07cadfe5ee9..31680ffd539 100644 --- a/packages/client/src/offline/selectors.ts +++ b/packages/client/src/offline/selectors.ts @@ -10,6 +10,7 @@ */ import { IOfflineDataState, IOfflineData } from '@client/offline/reducer' import { IStoreState } from '@client/store' +import { createSelector } from '@reduxjs/toolkit' import { merge } from 'lodash' const getOfflineState = (store: IStoreState): IOfflineDataState => store.offline @@ -46,6 +47,12 @@ export const getOfflineData = (store: IStoreState): IOfflineData => { return data } +export const getLocations = createSelector(getOfflineData, (data) => ({ + ...data.locations, + ...data.facilities, + ...data.offices +})) + export const selectCountryBackground = (store: IStoreState) => { const countryBackground = getKey(store, 'offlineData').config ?.LOGIN_BACKGROUND diff --git a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx index a7710e49002..88136be1408 100644 --- a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx +++ b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx @@ -11,7 +11,7 @@ /* eslint-disable */ import { InputField } from '@client/components/form/InputField' -import { DATE, PARAGRAPH, TEXT } from '@client/forms' +import { DATE, IFormFieldValue, PARAGRAPH, TEXT } from '@client/forms' import { DateField } from '@opencrvs/components/lib/DateField' import { Text } from '@opencrvs/components/lib/Text' import { TextInput } from '@opencrvs/components/lib/TextInput' @@ -27,11 +27,10 @@ import { } from './utils' import { Errors, getValidationErrorsForForm } from './validation' -import { ActionFormData } from '@opencrvs/commons' import { + ActionFormData, FieldConfig, FieldValue, - FieldValueByType, FileFieldValue } from '@opencrvs/commons/client' import { @@ -50,6 +49,14 @@ import { } from 'react-intl' import { FileInput } from './inputs/FileInput/FileInput' +import { BulletList } from '@client/v2-events/features/events/registered-fields/BulletList' +import { Checkbox } from '@client/v2-events/features/events/registered-fields/Checkbox' +import { Select } from '@client/v2-events/features/events/registered-fields/Select' +import { countries } from '@client/utils/countries' +import { SelectOption } from '@opencrvs/commons' +import { SelectCountry } from '@client/v2-events/features/events/registered-fields/SelectCountry' +import { formatISO } from 'date-fns' + const fadeIn = keyframes` from { opacity: 0; } to { opacity: 1; } @@ -72,7 +79,7 @@ interface GeneratedInputFieldProps { onChange: (e: React.ChangeEvent) => void onBlur: (e: React.FocusEvent) => void resetDependentSelectValues: (name: string) => void - value: FieldValueByType[FieldType['type']] + value: IFormFieldValue touched: boolean error: string formData: ActionFormData @@ -175,7 +182,7 @@ const GeneratedInputField = React.memo( return ( ) } + if (fieldDefinition.type === 'BULLET_LIST') { + return + } + if (fieldDefinition.type === 'SELECT') { + return ( + setFieldValue(props.id, val)} + /> + ) +} diff --git a/packages/client/src/v2-events/features/events/useEventConfiguration.ts b/packages/client/src/v2-events/features/events/useEventConfiguration.ts index 9f6fe018a73..399a9ae31e3 100644 --- a/packages/client/src/v2-events/features/events/useEventConfiguration.ts +++ b/packages/client/src/v2-events/features/events/useEventConfiguration.ts @@ -16,16 +16,10 @@ import { api } from '@client/v2-events/trpc' * @returns a list of event configurations */ export function useEventConfigurations() { - const [config] = api.config.get.useSuspenseQuery() + const [config] = api.event.config.get.useSuspenseQuery() 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 @@ -34,7 +28,7 @@ export function getAllFields(configuration: EventConfig) { export function useEventConfiguration(eventIdentifier: string): { eventConfiguration: EventConfig } { - const [config] = api.config.get.useSuspenseQuery() + const [config] = api.event.config.get.useSuspenseQuery() const eventConfiguration = config.find( (event) => event.id === eventIdentifier ) 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 33f7d9cea03..6261c5dc311 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 @@ -10,19 +10,18 @@ */ import { Mutation as TanstackMutation } from '@tanstack/query-core' -import { hashKey, useMutation } from '@tanstack/react-query' -import { getMutationKey, getQueryKey } from '@trpc/react-query' +import { useMutation } from '@tanstack/react-query' +import { getMutationKey } from '@trpc/react-query' import { ActionInput, EventDocument, getCurrentEventState } from '@opencrvs/commons/client' -import { ActionFormData } from '@opencrvs/commons' import { api, queryClient, utils } from '@client/v2-events/trpc' async function updateLocalEvent(updatedEvent: EventDocument) { utils.event.get.setData(updatedEvent.id, updatedEvent) - return utils.events.get.invalidate() + return utils.event.list.invalidate() } function waitUntilEventIsCreated( @@ -127,7 +126,7 @@ function updateEventOptimistically( ] } - utils.events.get.setData(undefined, (eventIndices) => + utils.event.list.setData(undefined, (eventIndices) => eventIndices ?.filter((ei) => ei.id !== optimisticEvent.id) .concat(getCurrentEventState(optimisticEvent)) diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts index b6a8f92aafb..f59a703f3e1 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/create.ts @@ -35,7 +35,7 @@ utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ } utils.event.get.setData(newEvent.transactionId, optimisticEvent) - utils.events.get.setData(undefined, (eventIndices) => + utils.event.list.setData(undefined, (eventIndices) => eventIndices?.concat(getCurrentEventState(optimisticEvent)) ) return optimisticEvent @@ -43,7 +43,7 @@ utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ onSuccess: async (response) => { utils.event.get.setData(response.id, response) utils.event.get.setData(response.transactionId, response) - await utils.events.get.invalidate() + await utils.event.list.invalidate() } })) diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts index 0a5ef9042a7..8ce8f6bb878 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/delete.ts @@ -43,7 +43,7 @@ utils.event.delete.setMutationDefaults(({ canonicalMutationFn }) => ({ }, retryDelay: 10000, onSuccess: ({ id }) => { - void utils.events.get.invalidate() + void utils.event.list.invalidate() }, /* * This ensures that when the application is reloaded with pending mutations in IndexedDB, the diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx index 0df45b34a10..2564643db77 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx @@ -9,7 +9,7 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ import { renderHook, RenderHookResult, waitFor } from '@testing-library/react' -import React, { act, PropsWithChildren } from 'react' +import React, { PropsWithChildren } from 'react' import { http, HttpResponse, HttpResponseResolver } from 'msw' import { setupServer } from 'msw/node' diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts index f03e50470ba..c40e9a7b812 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts @@ -55,7 +55,7 @@ function filterOutboxEventsWithMutation< } export function useEvents() { - const eventsList = api.events.get.useQuery().data ?? [] + const eventsList = api.event.list.useQuery().data ?? [] function getDrafts(): EventDocument[] { const queries = queryClient.getQueriesData({ @@ -98,7 +98,7 @@ export function useEvents() { return { createEvent, getEvent: api.event.get, - getEvents: api.events.get, + getEvents: api.event.list, deleteEvent: useDeleteEventMutation(), getOutbox, getDrafts, 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 9bfe834df50..7fedaadff26 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverview.tsx @@ -8,12 +8,13 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React, { useEffect } from 'react' -import { useSelector } from 'react-redux' +import React from 'react' import { useTypedParams } from 'react-router-typesafe-routes/dom' +import { useSelector } from 'react-redux' import { - ActionDocument, EventIndex, + ActionDocument, + getAllFields, SummaryConfig } from '@opencrvs/commons/client' import { Content, ContentSize } from '@opencrvs/components/lib/Content' @@ -21,51 +22,54 @@ 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' -// eslint-disable-next-line no-restricted-imports -import { getUserDetails } from '@client/profile/profileSelectors' -// eslint-disable-next-line no-restricted-imports -import { ProfileState } from '@client/profile/profileReducer' import { getInitialValues } from '@client/v2-events/components/forms/utils' import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents' import { useIntlFormatMessageWithFlattenedParams } from '@client/v2-events/features/workqueues/utils' -import { utils } from '@client/v2-events/trpc' +import { useUsers } from '@client/v2-events/hooks/useUsers' +// eslint-disable-next-line no-restricted-imports +import { getLocations } from '@client/offline/selectors' +import { withSuspense } from '@client/v2-events/components/withSuspense' +import { getUserIdsFromActions } from '@client/v2-events/utils' import { EventHistory } from './components/EventHistory' import { EventSummary } from './components/EventSummary' import { ActionMenu } from './components/ActionMenu' +import { EventOverviewProvider } from './EventOverviewContext' /** * Based on packages/client/src/views/RecordAudit/RecordAudit.tsx */ -export function EventOverviewIndex() { +function EventOverviewContainer() { const params = useTypedParams(ROUTES.V2.EVENTS.OVERVIEW) const { getEvents, getEvent } = useEvents() - const user = useSelector(getUserDetails) + const { getUsers } = useUsers() const [config] = useEventConfigurations() - const { data: fullEvent } = getEvent.useQuery(params.eventId) + const [fullEvent] = getEvent.useSuspenseQuery(params.eventId) + const [events] = getEvents.useSuspenseQuery() + const event = events.find((e) => e.id === params.eventId) - const { data: events } = getEvents.useQuery() + const userIds = getUserIdsFromActions(fullEvent.actions) + const [users] = getUsers.useSuspenseQuery(userIds) + const locations = useSelector(getLocations) - const event = events?.find((e) => e.id === params.eventId) - - if (!event || !fullEvent?.actions) { + if (!event) { return null } return ( - !action.draft)} - summary={config.summary} - user={user} - /> + + !action.draft)} + summary={config.summary} + /> + ) } @@ -75,13 +79,11 @@ export function EventOverviewIndex() { function EventOverview({ event, summary, - history, - user + history }: { event: EventIndex summary: SummaryConfig history: ActionDocument[] - user: ProfileState['userDetails'] }) { const { eventConfiguration } = useEventConfiguration(event.type) const intl = useIntlFormatMessageWithFlattenedParams() @@ -98,7 +100,9 @@ function EventOverview({ topActionButtons={[]} > - + ) } + +export const EventOverviewIndex = withSuspense(EventOverviewContainer) diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverviewContext.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverviewContext.tsx new file mode 100644 index 00000000000..186ce4cc253 --- /dev/null +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/EventOverviewContext.tsx @@ -0,0 +1,80 @@ +/* + * 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 React, { createContext, useContext } from 'react' +import { ResolvedUser } from '@opencrvs/commons/client' +// eslint-disable-next-line no-restricted-imports +import { ILocation } from '@client/offline/reducer' + +const EventOverviewContext = createContext<{ + getUser: (id: string) => ResolvedUser + getLocation: (id: string) => ILocation +} | null>(null) + +const MISSING_USER = { + id: 'ID_MISSING', + name: [ + { + use: 'en', + given: ['Missing'], + family: 'user' + } + ], + systemRole: '-' +} + +/** + * Provides methods for resolving users and locations within the event. + */ +export const EventOverviewProvider = ({ + children, + locations, + users +}: { + children: React.ReactNode + users: ResolvedUser[] + locations: Record +}) => { + const getUser = (id: string) => { + const user = users.find((u) => u.id === id) + + if (!user) { + // eslint-disable-next-line no-console + console.error(`User with id ${id} not found.`) + + return MISSING_USER + } + + return user + } + + const getLocation = (id: string) => { + // @TODO: Remove this fallback once developers have had time to update their data. + // eslint-disable-next-line + return locations[id] ?? { id, name: 'Unknown location' } + } + + return ( + + {children} + + ) +} + +export const useEventOverviewContext = () => { + const context = useContext(EventOverviewContext) + + if (!context) { + throw new Error('EventOverviewContext not found') + } + + return context +} diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx index e0697877614..0d7a9e11670 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/ActionMenu.tsx @@ -13,6 +13,7 @@ import React from 'react' import { useIntl } from 'react-intl' import { useNavigate } from 'react-router-dom' +import { formatISO } from 'date-fns' import { validate, ActionType } from '@opencrvs/commons/client' import { type ActionConfig } from '@opencrvs/commons' import { CaretDown } from '@opencrvs/components/lib/Icon/all-icons' @@ -42,7 +43,7 @@ export function ActionMenu({ eventId }: { eventId: string }) { const params = { $event: event, $user: authentication, - $now: new Date().toISOString().split('T')[0] + $now: formatISO(new Date(), { representation: 'date' }) } return validate(action.allowedWhen, params) diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/EventHistory.tsx similarity index 51% rename from packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory.tsx rename to packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/EventHistory.tsx index 6ea90641ff7..67a9114e702 100644 --- a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory.tsx +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/EventHistory.tsx @@ -9,22 +9,26 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ import React from 'react' +import { format } from 'date-fns' import styled from 'styled-components' import { useIntl } from 'react-intl' import { useNavigate } from 'react-router-dom' - +import { stringify } from 'query-string' import { Link } from '@opencrvs/components' import { ColumnContentAlignment } from '@opencrvs/components/lib/common-types' import { Divider } from '@opencrvs/components/lib/Divider' import { Text } from '@opencrvs/components/lib/Text' import { Table } from '@opencrvs/components/lib/Table' import { ActionDocument } from '@opencrvs/commons/client' -// eslint-disable-next-line no-restricted-imports -import { ProfileState } from '@client/profile/profileReducer' +import { ResolvedUser } from '@opencrvs/commons' +import { useModal } from '@client/v2-events/hooks/useModal' import { constantsMessages } from '@client/v2-events/messages' import * as routes from '@client/navigation/routes' import { formatUrl } from '@client/navigation' -import { formatLongDate } from '@client/utils/date-formatting' +import { useEventOverviewContext } from '@client/v2-events/features/workqueues/EventOverview/EventOverviewContext' +import { EventHistoryModal } from './EventHistoryModal' +import { UserAvatar } from './UserAvatar' +import { messages } from './messages' /** * Based on packages/client/src/views/RecordAudit/History.tsx @@ -34,69 +38,81 @@ const TableDiv = styled.div` overflow: auto; ` -const NameAvatar = styled.div` - display: flex; - align-items: center; - img { - margin-right: 10px; - } -` - -function GetNameWithAvatar() { - const userName = 'Unknown registar' +const DEFAULT_HISTORY_RECORD_PAGE_SIZE = 10 - return ( - - {userName} - - ) -} - -export function EventHistory({ - history, - user -}: { - history: ActionDocument[] - user: ProfileState['userDetails'] -}) { +/** + * Renders the event history table. Used for audit trail. + */ +export function EventHistory({ history }: { history: ActionDocument[] }) { const intl = useIntl() const navigate = useNavigate() + const [modal, openModal] = useModal() + const { getUser, getLocation } = useEventOverviewContext() - const DEFAULT_HISTORY_RECORD_PAGE_SIZE = 10 + const onHistoryRowClick = (item: ActionDocument, user: ResolvedUser) => { + void openModal((close) => ( + + )) + } + + const historyRows = history.map((item) => { + const user = getUser(item.createdBy) - const historyRows = history.map((item) => ({ - date: formatLongDate( - item.createdAt.toLocaleString(), - intl.locale, - 'MMMM dd, yyyy · hh.mm a' - ), + const location = getLocation(item.createdAtLocation) - action: ( - { - window.alert('not implemented') - }} - > - {item.type} - - ), - user: ( - - navigate( - formatUrl(routes.USER_PROFILE, { - userId: item.createdBy + return { + date: format( + new Date(item.createdAt), + intl.formatMessage(messages['event.history.timeFormat']) + ), + action: ( + { + onHistoryRowClick(item, user) + }} + > + {item.type} + + ), + user: ( + + navigate( + formatUrl(routes.USER_PROFILE, { + userId: item.createdBy + }) + ) + } + > + + + ), + role: user.systemRole, + location: ( + { + navigate({ + pathname: routes.TEAM_USER_LIST, + search: stringify({ + locationId: item.createdAtLocation + }) }) - ) - } - > - - - ) - })) + }} + > + {location.name} + + ) + } + }) const columns = [ { @@ -144,6 +160,7 @@ export function EventHistory({ pageSize={DEFAULT_HISTORY_RECORD_PAGE_SIZE} /> + {modal} ) } diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/EventHistoryModal.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/EventHistoryModal.tsx new file mode 100644 index 00000000000..a46139ad68b --- /dev/null +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/EventHistoryModal.tsx @@ -0,0 +1,64 @@ +/* + * 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 React from 'react' +import { useIntl } from 'react-intl' +import format from 'date-fns/format' +import { ResponsiveModal, Stack } from '@opencrvs/components' +import { Text } from '@opencrvs/components/lib/Text' +import { ActionDocument } from '@opencrvs/commons/client' +import { ResolvedUser } from '@opencrvs/commons' +import { getUsersFullName, joinValues } from '@client/v2-events/utils' +import { messages } from './messages' + +/** + * Detailed view of single Action, showing the history of the event. + * + * @TODO: Add more details to the modal and ability to diff changes when more events are specified. + */ +export function EventHistoryModal({ + history, + user, + close +}: { + history: ActionDocument + user: ResolvedUser + close: () => void +}) { + const intl = useIntl() + + const name = getUsersFullName(user.name, intl.locale) + return ( + + + + {joinValues( + [ + name, + format( + new Date(history.createdAt), + intl.formatMessage(messages['event.history.timeFormat']) + ) + ], + ' — ' + )} + + + + ) +} diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/UserAvatar.tsx b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/UserAvatar.tsx new file mode 100644 index 00000000000..e903d92611e --- /dev/null +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/UserAvatar.tsx @@ -0,0 +1,46 @@ +/* + * 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 React from 'react' +import styled from 'styled-components' +import { ResolvedUser } from '@opencrvs/commons/client' +import { Avatar, Maybe } from '@client/utils/gateway' +import { AvatarSmall } from '@client/components/Avatar' +import { getUsersFullName } from '@client/v2-events/utils' + +const NameAvatar = styled.div` + display: flex; + align-items: center; + img { + margin-right: 10px; + } +` + +/** + * @returns Avatar with full name of the user. + */ +export function UserAvatar({ + names, + avatar, + locale +}: { + names: ResolvedUser['name'] + avatar?: Maybe + locale: string +}) { + const name = getUsersFullName(names, locale) + + return ( + + + {name} + + ) +} diff --git a/packages/events/src/storage/index.ts b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/index.ts similarity index 93% rename from packages/events/src/storage/index.ts rename to packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/index.ts index 2f5099328f3..1ce221cd43f 100644 --- a/packages/events/src/storage/index.ts +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/index.ts @@ -9,4 +9,4 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -export * from './mongodb' +export * from './EventHistory' diff --git a/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/messages.ts b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/messages.ts new file mode 100644 index 00000000000..e5d2ade0f87 --- /dev/null +++ b/packages/client/src/v2-events/features/workqueues/EventOverview/components/EventHistory/messages.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. + */ + +import { defineMessages } from 'react-intl' + +export const messages = defineMessages({ + 'event.history.timeFormat': { + defaultMessage: 'MMMM dd, yyyy · hh.mm a', + id: 'event.history.timeFormat', + description: 'Time format for timestamps in event history' + } +}) diff --git a/packages/client/src/v2-events/features/workqueues/utils.tsx b/packages/client/src/v2-events/features/workqueues/utils.tsx index 0486ddec467..ffb04af9325 100644 --- a/packages/client/src/v2-events/features/workqueues/utils.tsx +++ b/packages/client/src/v2-events/features/workqueues/utils.tsx @@ -13,6 +13,11 @@ import { Dictionary, mapKeys } from 'lodash' import { MessageDescriptor, useIntl } from 'react-intl' const INTERNAL_SEPARATOR = '___' + +/** + * Replaces dots with triple underscores in the object keys. + * This is needed to support dot notation in the message variables. + */ function convertDotToTripleUnderscore( obj: T ): Dictionary { @@ -23,6 +28,10 @@ function convertDotToTripleUnderscore( return keysWithUnderscores } +/** + * Replace dots with triple underscores within the curly braces. + * This is needed to support dot notation in the message variables. + */ function convertDotInCurlyBraces(str: string): string { return str.replace(/{([^}]+)}/g, (match, content) => { // Replace dots with triple underscores within the curly braces @@ -31,6 +40,12 @@ function convertDotInCurlyBraces(str: string): string { }) } +/** + * intl with formatMessage that supports "flat object" dot notation in the message. + * + * @example intl.formatMessage(`string {with.nested.value}`, { + * 'with.nested.value': 'nested value'}) // string nested value` + */ export function useIntlFormatMessageWithFlattenedParams() { const intl = useIntl() diff --git a/packages/client/src/v2-events/hooks/useUsers.ts b/packages/client/src/v2-events/hooks/useUsers.ts new file mode 100644 index 00000000000..b48cbdc6711 --- /dev/null +++ b/packages/client/src/v2-events/hooks/useUsers.ts @@ -0,0 +1,18 @@ +/* + * 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 { api } from '@client/v2-events/trpc' + +export function useUsers() { + return { + getUsers: api.user.list + } +} diff --git a/packages/client/src/v2-events/messages/workqueue.ts b/packages/client/src/v2-events/messages/workqueue.ts index 9306152dbc4..6bd481f24c0 100644 --- a/packages/client/src/v2-events/messages/workqueue.ts +++ b/packages/client/src/v2-events/messages/workqueue.ts @@ -10,14 +10,14 @@ */ import { defineMessages, MessageDescriptor } from 'react-intl' -interface IWorkQueueMessages +interface IWorkqueueMessages extends Record { CREATED: MessageDescriptor DECLARED: MessageDescriptor REGISTERED: MessageDescriptor } -const messagesToDefine: IWorkQueueMessages = { +const messagesToDefine: IWorkqueueMessages = { CREATED: { id: 'wq.noRecords.CREATED', defaultMessage: 'No records in progress', @@ -36,4 +36,4 @@ const messagesToDefine: IWorkQueueMessages = { } /** @knipignore */ -export const wqMessages: IWorkQueueMessages = defineMessages(messagesToDefine) +export const wqMessages: IWorkqueueMessages = defineMessages(messagesToDefine) diff --git a/packages/client/src/v2-events/utils.ts b/packages/client/src/v2-events/utils.ts new file mode 100644 index 00000000000..a22d8548add --- /dev/null +++ b/packages/client/src/v2-events/utils.ts @@ -0,0 +1,56 @@ +/* + * 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 { uniq, isString, get } from 'lodash' +import { ResolvedUser, ActionDocument } from '@opencrvs/commons/client' + +/** + * + * Joins defined values using a separator and trims the result + */ +export function joinValues( + values: Array, + separator = ' ' +) { + return values + .filter((value) => !!value) + .join(separator) + .trim() +} + +export function getUsersFullName( + names: ResolvedUser['name'], + language: string +) { + const match = names.find((name) => name.use === language) ?? names[0] + + return joinValues([...match.given, match.family]) +} + +/** Utility to get all keys from union */ +type AllKeys = T extends T ? keyof T : never + +/** + * @returns unique ids of users are referenced in the ActionDocument array. + * Used for fetching user data in bulk. + */ +export const getUserIdsFromActions = (actions: ActionDocument[]) => { + const userIdFields = [ + 'createdBy', + 'assignedTo' + ] satisfies AllKeys[] + + const userIds = actions.flatMap((action) => + userIdFields.map((fieldName) => get(action, fieldName)).filter(isString) + ) + + return uniq(userIds) +} diff --git a/packages/commons/src/events/ActionDocument.ts b/packages/commons/src/events/ActionDocument.ts index 964a0c403d4..b0b28689b8b 100644 --- a/packages/commons/src/events/ActionDocument.ts +++ b/packages/commons/src/events/ActionDocument.ts @@ -85,6 +85,21 @@ export const ActionDocument = z.discriminatedUnion('type', [ ]) export type ActionDocument = z.infer + +export const ResolvedUser = z.object({ + id: z.string(), + systemRole: z.string(), + name: z.array( + z.object({ + use: z.string(), + given: z.array(z.string()), + family: z.string() + }) + ) +}) + +export type ResolvedUser = z.infer + export type CreatedAction = z.infer export type ActionFormData = ActionDocument['data'] diff --git a/packages/commons/src/events/ActionInput.ts b/packages/commons/src/events/ActionInput.ts index a1a3dd3cbc1..3034821ab9a 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,10 +39,13 @@ 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()) }) ) +export type ValidateActionInput = z.infer + export const NotifyActionInput = BaseActionInput.merge( z.object({ type: z.literal(ActionType.NOTIFY).default(ActionType.NOTIFY), @@ -50,6 +53,8 @@ export const NotifyActionInput = BaseActionInput.merge( }) ) +export type NotifyActionInput = z.infer + export const DeclareActionInput = BaseActionInput.merge( z.object({ type: z.literal(ActionType.DECLARE).default(ActionType.DECLARE) diff --git a/packages/commons/src/events/DeduplicationConfig.ts b/packages/commons/src/events/DeduplicationConfig.ts new file mode 100644 index 00000000000..35b3d107652 --- /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 75f3cbfd256..417d77466c3 100644 --- a/packages/commons/src/events/EventConfig.ts +++ b/packages/commons/src/events/EventConfig.ts @@ -13,7 +13,8 @@ import { ActionConfig } from './ActionConfig' import { TranslationConfig } from './TranslationConfig' import { SummaryConfig, SummaryConfigInput } from './SummaryConfig' import { WorkqueueConfig } from './WorkqueueConfig' -import { FormConfig, FormConfigInput } from './FormConfig' +import { FormConfig, FormConfigInput, FormPage } 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({ @@ -41,3 +43,6 @@ export type EventConfigInput = z.input export const defineForm = (form: FormConfigInput): FormConfig => FormConfig.parse(form) + +export const defineFormPage = (formPage: FormPage): FormPage => + FormPage.parse(formPage) diff --git a/packages/commons/src/events/FieldConfig.ts b/packages/commons/src/events/FieldConfig.ts index 1eacf7a4d3d..6687f631a1e 100644 --- a/packages/commons/src/events/FieldConfig.ts +++ b/packages/commons/src/events/FieldConfig.ts @@ -20,6 +20,7 @@ import { export const ConditionalTypes = { SHOW: 'SHOW', + HIDE: 'HIDE', ENABLE: 'ENABLE' } as const @@ -33,6 +34,11 @@ const ShowConditional = z.object({ conditional: Conditional() }) +const HideConditional = z.object({ + type: z.literal(ConditionalTypes.HIDE), + conditional: Conditional() +}) + const EnableConditional = z.object({ type: z.literal(ConditionalTypes.ENABLE), conditional: Conditional() @@ -40,12 +46,13 @@ const EnableConditional = z.object({ const FieldConditional = z.discriminatedUnion('type', [ ShowConditional, + HideConditional, EnableConditional ]) const BaseField = z.object({ id: FieldId, - conditionals: z.array(FieldConditional).optional().default([]), + conditionals: z.array(FieldConditional).default([]).optional(), initialValue: z .union([ z.string(), @@ -80,7 +87,11 @@ export const FieldType = { PARAGRAPH: 'PARAGRAPH', RADIO_GROUP: 'RADIO_GROUP', FILE: 'FILE', - HIDDEN: 'HIDDEN' + HIDDEN: 'HIDDEN', + BULLET_LIST: 'BULLET_LIST', + CHECKBOX: 'CHECKBOX', + SELECT: 'SELECT', + COUNTRY: 'COUNTRY' } as const export const fieldTypes = Object.values(FieldType) @@ -98,9 +109,10 @@ const TextField = BaseField.extend({ type: z.literal(FieldType.TEXT), options: z .object({ - maxLength: z.number().optional().describe('Maximum length of the text') + maxLength: z.number().optional().describe('Maximum length of the text'), + type: z.enum(['text', 'email', 'password', 'number']).optional() }) - .default({}) + .default({ type: 'text' }) .optional() }).describe('Text input') @@ -115,11 +127,22 @@ const DateField = BaseField.extend({ .optional() }).describe('A single date input (dd-mm-YYYY)') +const HTMLFontVariant = z.enum([ + 'reg12', + 'reg14', + 'reg16', + 'reg18', + 'h4', + 'h3', + 'h2', + 'h1' +]) + const Paragraph = BaseField.extend({ type: z.literal(FieldType.PARAGRAPH), options: z .object({ - fontVariant: z.literal('reg16').optional() + fontVariant: HTMLFontVariant.optional() }) .default({}) }).describe('A read-only HTML

paragraph') @@ -138,14 +161,44 @@ const RadioGroup = BaseField.extend({ ) }).describe('Grouped radio options') +const BulletList = BaseField.extend({ + type: z.literal(FieldType.BULLET_LIST), + items: z.array(TranslationConfig).describe('A list of items'), + font: HTMLFontVariant +}).describe('A list of bullet points') + +const SelectOption = z.object({ + value: z.string().describe('The value of the option'), + label: TranslationConfig.describe('The label of the option') +}) + +const Select = BaseField.extend({ + type: z.literal(FieldType.SELECT), + options: z.array(SelectOption).describe('A list of options') +}).describe('Select input') + +const Checkbox = BaseField.extend({ + type: z.literal(FieldType.CHECKBOX) +}).describe('Check Box') + +const Country = BaseField.extend({ + type: z.literal(FieldType.COUNTRY) +}).describe('Country select field') + export const FieldConfig = z.discriminatedUnion('type', [ TextField, DateField, Paragraph, RadioGroup, - File + BulletList, + Select, + Checkbox, + File, + Country ]) export type FieldConfig = z.infer export type FieldProps = Extract +export type SelectOption = z.infer +export type FieldConditional = z.infer diff --git a/packages/commons/src/events/FormConfig.ts b/packages/commons/src/events/FormConfig.ts index c59a4912af4..63a9f7ac9a3 100644 --- a/packages/commons/src/events/FormConfig.ts +++ b/packages/commons/src/events/FormConfig.ts @@ -12,7 +12,7 @@ import { z } from 'zod' import { FieldConfig } from './FieldConfig' import { TranslationConfig } from './TranslationConfig' -const FormPage = z.object({ +export const FormPage = z.object({ id: z.string().describe('Unique identifier for the page'), title: TranslationConfig.describe('Header title of the page'), fields: z.array(FieldConfig).describe('Fields to be rendered on the page') diff --git a/packages/commons/src/events/index.ts b/packages/commons/src/events/index.ts index ae6d21b7f26..70aa15aa1fa 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/state/index.ts b/packages/commons/src/events/state/index.ts index c016a2447ee..e9772469eba 100644 --- a/packages/commons/src/events/state/index.ts +++ b/packages/commons/src/events/state/index.ts @@ -39,14 +39,14 @@ function getStatusFromActions(actions: Array) { } function getAssignedUserFromActions(actions: Array) { - return actions.reduce((status, action) => { + return actions.reduce((user, action) => { if (action.type === ActionType.ASSIGN) { return action.assignedTo } if (action.type === ActionType.UNASSIGN) { return null } - return status + return user }, null) } diff --git a/packages/commons/src/events/utils.ts b/packages/commons/src/events/utils.ts index 0e02cc43cc1..6cedbeadde7 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/src/users/service.ts b/packages/commons/src/users/service.ts index d23cf3af43c..9c3f2172dd1 100644 --- a/packages/commons/src/users/service.ts +++ b/packages/commons/src/users/service.ts @@ -41,7 +41,11 @@ export async function getUser( userId: string, token: string ) { - const res = await fetch(new URL(`getUser`, userManagementHost).href, { + const hostWithTrailingSlash = userManagementHost.endsWith('/') + ? userManagementHost + : userManagementHost + '/' + + const res = await fetch(new URL('getUser', hostWithTrailingSlash).href, { method: 'POST', body: JSON.stringify({ userId }), headers: { diff --git a/packages/commons/tsconfig.esm.json b/packages/commons/tsconfig.esm.json index 6326cce88b1..f542c66309d 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 242e9ba1d60..887dd84ee90 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 160f2d63337..e17df6e4609 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 6f273f39f92..fc1a4e83b29 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/data-seeder/src/locations.ts b/packages/data-seeder/src/locations.ts index 936c2932cb6..d348d5a26cb 100644 --- a/packages/data-seeder/src/locations.ts +++ b/packages/data-seeder/src/locations.ts @@ -15,13 +15,19 @@ import { TypeOf, z } from 'zod' import { raise } from './utils' import { inspect } from 'util' +const LOCATION_TYPES = [ + 'ADMIN_STRUCTURE', + 'HEALTH_FACILITY', + 'CRVS_OFFICE' +] as const + const LocationSchema = z.array( z.object({ id: z.string(), name: z.string(), alias: z.string().optional(), partOf: z.string(), - locationType: z.enum(['ADMIN_STRUCTURE', 'HEALTH_FACILITY', 'CRVS_OFFICE']), + locationType: z.enum(LOCATION_TYPES), jurisdictionType: z .enum([ 'STATE', @@ -183,36 +189,42 @@ async function getLocations() { return parsedLocations.data } -function locationBundleToIdentifier( - bundle: fhir3.Bundle -): string[] { - return (bundle.entry ?? []) +const bundleToLocationEntries = (bundle: fhir3.Bundle) => + (bundle.entry ?? []) .map((bundleEntry) => bundleEntry.resource) .filter((maybeLocation): maybeLocation is fhir3.Location => Boolean(maybeLocation) ) - .map((location) => - location.identifier - ?.find( - ({ system }) => - system === `${OPENCRVS_SPECIFICATION_URL}id/statistical-code` || - system === `${OPENCRVS_SPECIFICATION_URL}id/internal-id` - ) - ?.value?.split('_') - .pop() - ) + +function locationBundleToIdentifier( + bundle: fhir3.Bundle +): string[] { + return bundleToLocationEntries(bundle) + .map((location) => getExternalIdFromIdentifier(location.identifier)) .filter((maybeId): maybeId is string => Boolean(maybeId)) } +/** + * Get the externally defined id for location. Defined in country-config. + */ +const getExternalIdFromIdentifier = ( + identifiers: fhir3.Location['identifier'] +) => + identifiers + ?.find(({ system }) => + [ + `${OPENCRVS_SPECIFICATION_URL}id/statistical-code`, + `${OPENCRVS_SPECIFICATION_URL}id/internal-id` + ].some((identifierSystem) => identifierSystem === system) + ) + ?.value?.split('_') + .pop() + export async function seedLocations(token: string) { const savedLocations = ( await Promise.all( - ['ADMIN_STRUCTURE', 'CRVS_OFFICE', 'HEALTH_FACILITY'].map((type) => - fetch(`${env.GATEWAY_HOST}/locations?type=${type}&_count=0`, { - headers: { - 'Content-Type': 'application/fhir+json' - } - }) + LOCATION_TYPES.map((type) => + getLocationsByType(type) .then((res) => res.json()) .then((bundle: fhir3.Bundle) => locationBundleToIdentifier(bundle) @@ -241,9 +253,11 @@ export async function seedLocations(token: string) { })) ) }) + if (!res.ok) { raise(await res.json()) } + const response: fhir3.Bundle = await res.json() response.entry?.forEach((res, index) => { if (res.response?.status !== '201') { @@ -266,14 +280,37 @@ function updateLocationPartOf(partOf: string) { return parent } -export async function seedLocationsForV2Events(token: string) { - const locations = await getLocations() +function getLocationsByType(type: string) { + return fetch(`${env.GATEWAY_HOST}/locations?type=${type}&_count=0`, { + headers: { + 'Content-Type': 'application/fhir+json' + } + }) +} - const simplifiedLocations = locations.map((location) => ({ - id: location.id, - name: location.name, - partOf: updateLocationPartOf(location.partOf) - })) +/** + * NOTE: Seeding locations for v2 should be done after seeding the legacy locations. + * This is because the v2 locations are created based on the legacy location ids to ensure compatibility. + */ +export async function seedLocationsForV2Events(token: string) { + const locations = ( + await Promise.all( + LOCATION_TYPES.map((type) => + getLocationsByType(type) + .then((res) => res.json()) + .then((bundle: fhir3.Bundle) => + bundleToLocationEntries(bundle).map((location) => ({ + id: location.id, + externalId: getExternalIdFromIdentifier(location.identifier), + name: location.name, + partOf: location?.partOf?.reference + ? updateLocationPartOf(location?.partOf?.reference) + : null + })) + ) + ) + ) + ).flat() // NOTE: TRPC expects certain format, which may seem unconventional. const res = await fetch(`${env.GATEWAY_HOST}/events/locations.set`, { @@ -282,12 +319,13 @@ export async function seedLocationsForV2Events(token: string) { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ json: simplifiedLocations }) + body: JSON.stringify({ json: locations }) }) if (!res.ok) { console.error( 'Unable to seed locations for v2 events. Ensure events service is running.' ) + console.error(JSON.stringify(await res.json())) } } diff --git a/packages/documents/tsconfig.json b/packages/documents/tsconfig.json index 66f852f5d92..507b7c99a4a 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 19a13e5ccff..9e35ab4a26e 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/environment.ts b/packages/events/src/environment.ts index 5d3457caef3..e57d6adb681 100644 --- a/packages/events/src/environment.ts +++ b/packages/events/src/environment.ts @@ -11,10 +11,15 @@ import { cleanEnv, url } from 'envalid' +/** + * When defining variables aim to be consistent with existing values. + * Define URLs without trailing slashes, include the protocol. + */ export const env = cleanEnv(process.env, { - MONGO_URL: url({ devDefault: 'mongodb://localhost/events' }), - ES_HOST: url({ devDefault: 'http://localhost:9200' }), + EVENTS_MONGO_URL: url({ devDefault: 'mongodb://localhost/events' }), + USER_MGNT_MONGO_URL: url({ devDefault: 'mongodb://localhost/user-mgnt' }), + ES_URL: url({ devDefault: 'http://localhost:9200' }), COUNTRY_CONFIG_URL: url({ devDefault: 'http://localhost:3040' }), DOCUMENTS_URL: url({ devDefault: 'http://localhost:9050' }), - USER_MANAGEMENT_URL: url({ devDefault: 'http://localhost:3030/' }) + USER_MANAGEMENT_URL: url({ devDefault: 'http://localhost:3030' }) }) diff --git a/packages/events/src/router/__snapshots__/event.create.test.ts.snap b/packages/events/src/router/__snapshots__/event.create.test.ts.snap index 428fa3d5879..56e1659946e 100644 --- a/packages/events/src/router/__snapshots__/event.create.test.ts.snap +++ b/packages/events/src/router/__snapshots__/event.create.test.ts.snap @@ -1,3 +1,40 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`event can be created and fetched 1`] = ` +{ + "actions": [ + { + "createdAt": "", + "createdAtLocation": { + "externalId": "", + "id": "", + "name": "Location name 0", + "partOf": null, + }, + "createdBy": { + "id": "", + "name": [ + { + "family": "Doe", + "given": [ + "John", + ], + "use": "en", + }, + ], + "systemRole": "REGISTRATION_AGENT", + }, + "data": {}, + "draft": false, + "type": "CREATE", + }, + ], + "createdAt": "", + "id": "", + "transactionId": "", + "type": "TENNIS_CLUB_MEMBERSHIP", + "updatedAt": "", +} +`; + exports[`event with unknown type cannot be created 1`] = `[TRPCError: Invalid event type EVENT_TYPE_THAT_DOES_NOT_EXIST. Valid event types are: TENNIS_CLUB_MEMBERSHIP, TENNIS_CLUB_MEMBERSHIP_PREMIUM]`; diff --git a/packages/events/src/router/__snapshots__/event.get.test.ts.snap b/packages/events/src/router/__snapshots__/event.get.test.ts.snap index 3fe3401e3b4..09f13b76d89 100644 --- a/packages/events/src/router/__snapshots__/event.get.test.ts.snap +++ b/packages/events/src/router/__snapshots__/event.get.test.ts.snap @@ -1,3 +1,131 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Returns 404 when not found 1`] = `[TRPCError: Event not found with ID: id-not-persisted]`; + +exports[`Returns event 1`] = ` +{ + "actions": [ + { + "createdAt": "", + "createdAtLocation": { + "externalId": "", + "id": "", + "name": "Location name 0", + "partOf": null, + }, + "createdBy": { + "id": "", + "name": [ + { + "family": "Doe", + "given": [ + "John", + ], + "use": "en", + }, + ], + "systemRole": "REGISTRATION_AGENT", + }, + "data": {}, + "draft": false, + "type": "CREATE", + }, + ], + "createdAt": "", + "id": "", + "transactionId": "", + "type": "TENNIS_CLUB_MEMBERSHIP", + "updatedAt": "", +} +`; + +exports[`Returns event with all actions resolved 1`] = ` +{ + "actions": [ + { + "createdAt": "", + "createdAtLocation": { + "externalId": "", + "id": "", + "name": "Location name 0", + "partOf": null, + }, + "createdBy": { + "id": "", + "name": [ + { + "family": "Doe", + "given": [ + "John", + ], + "use": "en", + }, + ], + "systemRole": "REGISTRATION_AGENT", + }, + "data": {}, + "draft": false, + "type": "CREATE", + }, + { + "createdAt": "", + "createdAtLocation": { + "externalId": "", + "id": "", + "name": "Location name 0", + "partOf": null, + }, + "createdBy": { + "id": "", + "name": [ + { + "family": "Doe", + "given": [ + "John", + ], + "use": "en", + }, + ], + "systemRole": "REGISTRATION_AGENT", + }, + "data": { + "name": "John Doe", + }, + "draft": false, + "type": "DECLARE", + }, + { + "createdAt": "", + "createdAtLocation": { + "externalId": "", + "id": "", + "name": "Location name 0", + "partOf": null, + }, + "createdBy": { + "id": "", + "name": [ + { + "family": "Doe", + "given": [ + "John", + ], + "use": "en", + }, + ], + "systemRole": "REGISTRATION_AGENT", + }, + "data": { + "favouritePlayer": "Elena Rybakina", + }, + "draft": false, + "type": "VALIDATE", + }, + ], + "createdAt": "", + "id": "", + "transactionId": "", + "type": "TENNIS_CLUB_MEMBERSHIP", + "updatedAt": "", +} +`; diff --git a/packages/events/src/router/event.get.test.ts b/packages/events/src/router/event.get.test.ts deleted file mode 100644 index 9b4cf8e987f..00000000000 --- a/packages/events/src/router/event.get.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' - -const client = createTestClient() -const generator = payloadGenerator() - -test('Returns 404 when not found', async () => { - await expect( - client.event.get('id-not-persisted') - ).rejects.toThrowErrorMatchingSnapshot() -}) - -test('Returns event', async () => { - const event = await client.event.create(generator.event.create()) - - const fetchedEvent = await client.event.get(event.id) - - expect(fetchedEvent).toEqual(event) -}) diff --git a/packages/events/src/router/event/__snapshots__/event.create.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.create.test.ts.snap new file mode 100644 index 00000000000..428fa3d5879 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.create.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`event with unknown type cannot be created 1`] = `[TRPCError: Invalid event type EVENT_TYPE_THAT_DOES_NOT_EXIST. Valid event types are: TENNIS_CLUB_MEMBERSHIP, TENNIS_CLUB_MEMBERSHIP_PREMIUM]`; diff --git a/packages/events/src/router/event/__snapshots__/event.get.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.get.test.ts.snap new file mode 100644 index 00000000000..3fe3401e3b4 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.get.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Returns 404 when not found 1`] = `[TRPCError: Event not found with ID: id-not-persisted]`; diff --git a/packages/events/src/router/event/__snapshots__/event.patch.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.patch.test.ts.snap new file mode 100644 index 00000000000..c1144aaf676 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.patch.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Prevents updating event with unknown type 1`] = `[TRPCError: Invalid event type EVENT_TYPE_THAT_DOES_NOT_EXIST. Valid event types are: TENNIS_CLUB_MEMBERSHIP, TENNIS_CLUB_MEMBERSHIP_PREMIUM]`; diff --git a/packages/events/src/router/event.actions.test.ts b/packages/events/src/router/event/event.actions.test.ts similarity index 85% rename from packages/events/src/router/event.actions.test.ts rename to packages/events/src/router/event/event.actions.test.ts index 55115933cff..1d1cd49e7ff 100644 --- a/packages/events/src/router/event.actions.test.ts +++ b/packages/events/src/router/event/event.actions.test.ts @@ -9,14 +9,13 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' import { ActionType } from '@opencrvs/commons' - -const client = createTestClient() -const generator = payloadGenerator() +import { createTestClient, setupTestCase } from '@events/tests/utils' test('actions can be added to created events', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + const originalEvent = await client.event.create(generator.event.create()) const event = await client.event.actions.declare( @@ -30,6 +29,9 @@ test('actions can be added to created events', async () => { }) test('Action data can be retrieved', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + const originalEvent = await client.event.create(generator.event.create()) const data = { name: 'John Doe', favouriteFruit: 'Banana' } diff --git a/packages/events/src/router/event.create.test.ts b/packages/events/src/router/event/event.create.test.ts similarity index 72% rename from packages/events/src/router/event.create.test.ts rename to packages/events/src/router/event/event.create.test.ts index eedad0a1a2e..d716a864118 100644 --- a/packages/events/src/router/event.create.test.ts +++ b/packages/events/src/router/event/event.create.test.ts @@ -9,14 +9,11 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { getClient } from '@events/storage/__mocks__/mongodb' -import { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' - -const client = createTestClient() -const generator = payloadGenerator() +import { createTestClient, setupTestCase } from '@events/tests/utils' test('event can be created and fetched', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) const event = await client.event.create(generator.event.create()) const fetchedEvent = await client.event.get(event.id) @@ -25,17 +22,20 @@ test('event can be created and fetched', async () => { }) test('creating an event is an idempotent operation', async () => { - const db = await getClient() + const { user, generator, eventsDb } = await setupTestCase() + const client = createTestClient(user) const payload = generator.event.create() await client.event.create(payload) await client.event.create(payload) - expect(await db.collection('events').find().toArray()).toHaveLength(1) + expect(await eventsDb.collection('events').find().toArray()).toHaveLength(1) }) test('event with unknown type cannot be created', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) await expect( client.event.create( generator.event.create({ diff --git a/packages/events/src/router/event/event.get.test.ts b/packages/events/src/router/event/event.get.test.ts new file mode 100644 index 00000000000..ec6a624c723 --- /dev/null +++ b/packages/events/src/router/event/event.get.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { createTestClient, setupTestCase } from '@events/tests/utils' + +test('Returns 404 when not found', async () => { + const { user } = await setupTestCase() + const client = createTestClient(user) + + await expect( + client.event.get('id-not-persisted') + ).rejects.toThrowErrorMatchingSnapshot() +}) + +test('Returns event', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + + const fetchedEvent = await client.event.get(event.id) + + expect(fetchedEvent).toEqual(event) +}) + +test('Returns event with all actions', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + + await client.event.actions.declare( + generator.event.actions.declare(event.id, { data: { name: 'John Doe' } }) + ) + + await client.event.actions.validate( + generator.event.actions.validate(event.id, { + data: { favouritePlayer: 'Elena Rybakina' } + }) + ) + + const fetchedEvent = await client.event.get(event.id) + + expect(fetchedEvent.actions).toHaveLength(3) +}) diff --git a/packages/events/src/router/events.get.test.ts b/packages/events/src/router/event/event.list.test.ts similarity index 75% rename from packages/events/src/router/events.get.test.ts rename to packages/events/src/router/event/event.list.test.ts index ede4831789e..8ca23766f88 100644 --- a/packages/events/src/router/events.get.test.ts +++ b/packages/events/src/router/event/event.list.test.ts @@ -8,30 +8,36 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' -import { EventStatus } from '@opencrvs/commons' -const client = createTestClient() -const generator = payloadGenerator() +import { EventStatus } from '@opencrvs/commons' +import { createTestClient, setupTestCase } from '@events/tests/utils' test('Returns empty list when no events', async () => { - const fetchedEvents = await client.events.get() + const { user } = await setupTestCase() + const client = createTestClient(user) + + const fetchedEvents = await client.event.list() expect(fetchedEvents).toEqual([]) }) test('Returns multiple events', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + for (let i = 0; i < 10; i++) { await client.event.create(generator.event.create()) } - const events = await client.events.get() + const events = await client.event.list() expect(events).toHaveLength(10) }) test('Returns aggregated event with updated status and values', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + const initialData = { name: 'John Doe', favouriteFruit: 'Banana' } const event = await client.event.create(generator.event.create()) await client.event.actions.declare( @@ -40,7 +46,7 @@ test('Returns aggregated event with updated status and values', async () => { }) ) - const initialEvents = await client.events.get() + const initialEvents = await client.event.list() expect(initialEvents).toHaveLength(1) expect(initialEvents[0].status).toBe(EventStatus.DECLARED) @@ -53,7 +59,7 @@ test('Returns aggregated event with updated status and values', async () => { }) ) - const updatedEvents = await client.events.get() + const updatedEvents = await client.event.list() expect(updatedEvents).toHaveLength(1) diff --git a/packages/events/src/router/event.patch.test.ts b/packages/events/src/router/event/event.patch.test.ts similarity index 82% rename from packages/events/src/router/event.patch.test.ts rename to packages/events/src/router/event/event.patch.test.ts index 6fdebce2d9f..8bc28e8bc3c 100644 --- a/packages/events/src/router/event.patch.test.ts +++ b/packages/events/src/router/event/event.patch.test.ts @@ -8,14 +8,13 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' -const client = createTestClient() - -const generator = payloadGenerator() +import { createTestClient, setupTestCase } from '@events/tests/utils' test('Prevents updating event with unknown type', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + const event = await client.event.create(generator.event.create()) await expect( @@ -28,6 +27,9 @@ test('Prevents updating event with unknown type', async () => { }) test('stored events can be modified', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + const originalEvent = await client.event.create(generator.event.create()) const event = await client.event.patch( diff --git a/packages/events/src/router/event/index.ts b/packages/events/src/router/event/index.ts new file mode 100644 index 00000000000..f7fe39159ec --- /dev/null +++ b/packages/events/src/router/event/index.ts @@ -0,0 +1,149 @@ +/* + * 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 { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { getEventWithOnlyUserSpecificDrafts } from '@events/drafts' +import { getEventConfigurations } from '@events/service/config/config' +import { + addAction, + createEvent, + deleteEvent, + EventInputWithId, + getEventById, + patchEvent +} from '@events/service/events/events' +import { presignFilesInEvent } from '@events/service/files' +import { getIndexedEvents } from '@events/service/indexing/indexing' +import { EventConfig, getUUID } from '@opencrvs/commons' +import { + DeclareActionInput, + EventIndex, + EventInput, + NotifyActionInput, + RegisterActionInput, + ValidateActionInput +} from '@opencrvs/commons/events' +import { router, publicProcedure } from '@events/router/trpc' + +const validateEventType = ({ + eventTypes, + eventInputType +}: { + eventTypes: string[] + eventInputType: string +}) => { + if (!eventTypes.includes(eventInputType)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid event type ${eventInputType}. Valid event types are: ${eventTypes.join( + ', ' + )}` + }) + } +} + +export const eventRouter = router({ + config: router({ + get: publicProcedure.output(z.array(EventConfig)).query(async (options) => { + return getEventConfigurations(options.ctx.token) + }) + }), + create: publicProcedure.input(EventInput).mutation(async (options) => { + const config = await getEventConfigurations(options.ctx.token) + const eventIds = config.map((c) => c.id) + + validateEventType({ + eventTypes: eventIds, + eventInputType: options.input.type + }) + + return createEvent({ + eventInput: options.input, + createdBy: options.ctx.user.id, + createdAtLocation: options.ctx.user.primaryOfficeId, + transactionId: options.input.transactionId + }) + }), + patch: publicProcedure.input(EventInputWithId).mutation(async (options) => { + const config = await getEventConfigurations(options.ctx.token) + const eventIds = config.map((c) => c.id) + + validateEventType({ + eventTypes: eventIds, + eventInputType: options.input.type + }) + + return patchEvent(options.input) + }), + get: publicProcedure.input(z.string()).query(async ({ input, ctx }) => { + const event = await getEventById(input) + + const eventWithSignedFiles = await presignFilesInEvent(event, ctx.token) + const eventWithUserSpecificDrafts = getEventWithOnlyUserSpecificDrafts( + eventWithSignedFiles, + ctx.user.id + ) + + return eventWithUserSpecificDrafts + }), + delete: publicProcedure + .input(z.object({ eventId: z.string() })) + .mutation(async ({ input, ctx }) => { + 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 + }) + }), + register: publicProcedure + .input(RegisterActionInput.omit({ identifiers: true })) + .mutation((options) => { + return addAction( + { + ...options.input, + identifiers: { + trackingId: getUUID(), + registrationNumber: getUUID() + } + }, + { + eventId: options.input.eventId, + createdBy: options.ctx.user.id, + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token + } + ) + }) + }), + list: publicProcedure.output(z.array(EventIndex)).query(getIndexedEvents) +}) diff --git a/packages/events/src/router/locations/__snapshots__/locations.set.test.ts.snap b/packages/events/src/router/locations/__snapshots__/locations.set.test.ts.snap new file mode 100644 index 00000000000..0d5bacfde2e --- /dev/null +++ b/packages/events/src/router/locations/__snapshots__/locations.set.test.ts.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Prevents sending empty payload 1`] = ` +[TRPCError: [ + { + "code": "too_small", + "minimum": 1, + "type": "array", + "inclusive": true, + "exact": false, + "message": "Array must contain at least 1 element(s)", + "path": [] + } +]] +`; + +exports[`prevents unauthorized access from registrar 1`] = `[TRPCError: UNAUTHORIZED]`; diff --git a/packages/events/src/router/locations/index.ts b/packages/events/src/router/locations/index.ts new file mode 100644 index 00000000000..86913409fa2 --- /dev/null +++ b/packages/events/src/router/locations/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { router, publicProcedure } from '@events/router/trpc' +import { middleware } from '@events/router/middleware/middleware' +import { + getLocations, + Location, + setLocations +} from '@events/service/locations/locations' +import { z } from 'zod' + +export const locationRouter = router({ + set: publicProcedure + .use(middleware.isNationalSystemAdminUser) + .input(z.array(Location).min(1)) + .mutation(async (options) => { + await setLocations(options.input) + }), + get: publicProcedure.output(z.array(Location)).query(getLocations) +}) diff --git a/packages/events/src/router/locations.get.test.ts b/packages/events/src/router/locations/locations.get.test.ts similarity index 51% rename from packages/events/src/router/locations.get.test.ts rename to packages/events/src/router/locations/locations.get.test.ts index b64b5936a21..d2c1cf51b9e 100644 --- a/packages/events/src/router/locations.get.test.ts +++ b/packages/events/src/router/locations/locations.get.test.ts @@ -8,38 +8,36 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' +import { createTestClient, setupTestCase } from '@events/tests/utils' import { userScopes } from '@opencrvs/commons' -const nationalSystemAdminClient = createTestClient([ - userScopes.nationalSystemAdmin -]) -const generator = payloadGenerator() - -test('Returns empty list when no locations are set', async () => { - const locations = await nationalSystemAdminClient.locations.get() - - expect(locations).toEqual([]) -}) - test('Returns single location in right format', async () => { + const { user } = await setupTestCase() + const client = createTestClient(user, [userScopes.nationalSystemAdmin]) + const setLocationPayload = [ - { id: '123-456-789', partOf: null, name: 'Location foobar' } + { + id: '123-456-789', + partOf: null, + name: 'Location foobar', + externalId: 'ext-id-123' + } ] - await nationalSystemAdminClient.locations.set(setLocationPayload) + await client.locations.set(setLocationPayload) - const locations = await nationalSystemAdminClient.locations.get() + const locations = await client.locations.get() expect(locations).toHaveLength(1) expect(locations).toMatchObject(setLocationPayload) }) test('Returns multiple locations', async () => { - await nationalSystemAdminClient.locations.set(generator.locations.set(5)) + const { user, generator } = await setupTestCase() + const client = createTestClient(user, [userScopes.nationalSystemAdmin]) + await client.locations.set(generator.locations.set(5)) - const locations = await nationalSystemAdminClient.locations.get() + const locations = await client.locations.get() expect(locations).toHaveLength(5) }) diff --git a/packages/events/src/router/locations.set.test.ts b/packages/events/src/router/locations/locations.set.test.ts similarity index 71% rename from packages/events/src/router/locations.set.test.ts rename to packages/events/src/router/locations/locations.set.test.ts index 68ec05b3c97..4f32b05f807 100644 --- a/packages/events/src/router/locations.set.test.ts +++ b/packages/events/src/router/locations/locations.set.test.ts @@ -8,39 +8,53 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { createTestClient } from '@events/tests/utils' -import { payloadGenerator } from '@events/tests/generators' +import { createTestClient, setupTestCase } from '@events/tests/utils' import { userScopes } from '@opencrvs/commons' -const nationalSystemAdminClient = createTestClient([ - userScopes.nationalSystemAdmin -]) - -const registrarClient = createTestClient() - -const generator = payloadGenerator() - test('prevents unauthorized access from registrar', async () => { + const { user } = await setupTestCase() + const registrarClient = createTestClient(user) + await expect( registrarClient.locations.set([]) ).rejects.toThrowErrorMatchingSnapshot() }) test('Allows national system admin to set locations', async () => { + const { user, generator } = await setupTestCase() + const nationalSystemAdminClient = createTestClient(user, [ + userScopes.nationalSystemAdmin + ]) + await expect( nationalSystemAdminClient.locations.set(generator.locations.set(1)) ).resolves.toEqual(undefined) }) test('Prevents sending empty payload', async () => { + const { user } = await setupTestCase() + const nationalSystemAdminClient = createTestClient(user, [ + userScopes.nationalSystemAdmin + ]) + await expect( nationalSystemAdminClient.locations.set([]) ).rejects.toThrowErrorMatchingSnapshot() }) test('Creates single location', async () => { + const { user } = await setupTestCase() + const nationalSystemAdminClient = createTestClient(user, [ + userScopes.nationalSystemAdmin + ]) + const locationPayload = [ - { id: '123-456-789', partOf: null, name: 'Location foobar' } + { + id: '123-456-789', + partOf: null, + name: 'Location foobar', + externalId: 'foo-bar' + } ] await nationalSystemAdminClient.locations.set(locationPayload) @@ -52,6 +66,11 @@ test('Creates single location', async () => { }) test('Creates multiple locations', async () => { + const { user, generator } = await setupTestCase() + const nationalSystemAdminClient = createTestClient(user, [ + userScopes.nationalSystemAdmin + ]) + const parentId = 'parent-id' const locationPayload = generator.locations.set([ @@ -69,6 +88,11 @@ test('Creates multiple locations', async () => { }) test('Removes existing locations not in payload', async () => { + const { user, generator } = await setupTestCase() + const nationalSystemAdminClient = createTestClient(user, [ + userScopes.nationalSystemAdmin + ]) + const initialPayload = generator.locations.set(5) await nationalSystemAdminClient.locations.set(initialPayload) diff --git a/packages/events/src/router/router.ts b/packages/events/src/router/router.ts index 42318ab6251..e1c3220afc5 100644 --- a/packages/events/src/router/router.ts +++ b/packages/events/src/router/router.ts @@ -9,173 +9,19 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { initTRPC, TRPCError } from '@trpc/server' -import superjson from 'superjson' -import { z } from 'zod' +import { router } from '@events/router/trpc' -import { getEventWithOnlyUserSpecificDrafts } from '@events/drafts' -import { getEventConfigurations } from '@events/service/config/config' -import { - addAction, - createEvent, - deleteEvent, - EventInputWithId, - getEventById, - patchEvent -} from '@events/service/events' -import { presignFilesInEvent } from '@events/service/files' -import { - getLocations, - Location, - setLocations -} from '@events/service/locations/locations' -import { Context, middleware } from './middleware/middleware' -import { getIndexedEvents } from '@events/service/indexing/indexing' -import { EventConfig, getUUID } from '@opencrvs/commons' -import { - DeclareActionInput, - EventIndex, - EventInput, - NotifyActionInput, - RegisterActionInput, - ValidateActionInput -} from '@opencrvs/commons/events' +import { eventRouter } from './event' +import { userRouter } from './user' +import { locationRouter } from './locations' -const validateEventType = ({ - eventTypes, - eventInputType -}: { - eventTypes: string[] - eventInputType: string -}) => { - if (!eventTypes.includes(eventInputType)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Invalid event type ${eventInputType}. Valid event types are: ${eventTypes.join( - ', ' - )}` - }) - } -} - -export const t = initTRPC.context().create({ - transformer: superjson +export const appRouter = router({ + event: eventRouter, + user: userRouter, + locations: locationRouter }) -const router = t.router -const publicProcedure = t.procedure - /** * @public */ export type AppRouter = typeof appRouter - -export const appRouter = router({ - config: router({ - get: publicProcedure.output(z.array(EventConfig)).query(async (options) => { - return getEventConfigurations(options.ctx.token) - }) - }), - event: router({ - create: publicProcedure.input(EventInput).mutation(async (options) => { - const config = await getEventConfigurations(options.ctx.token) - const eventIds = config.map((c) => c.id) - - validateEventType({ - eventTypes: eventIds, - eventInputType: options.input.type - }) - - return createEvent({ - eventInput: options.input, - createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId, - transactionId: options.input.transactionId - }) - }), - patch: publicProcedure.input(EventInputWithId).mutation(async (options) => { - const config = await getEventConfigurations(options.ctx.token) - const eventIds = config.map((c) => c.id) - - validateEventType({ - eventTypes: eventIds, - eventInputType: options.input.type - }) - - return patchEvent(options.input) - }), - get: publicProcedure.input(z.string()).query(async ({ input, ctx }) => { - const event = await getEventById(input) - const eventWithSignedFiles = await presignFilesInEvent(event, ctx.token) - const eventWithUserSpecificDrafts = getEventWithOnlyUserSpecificDrafts( - eventWithSignedFiles, - ctx.user.id - ) - return eventWithUserSpecificDrafts - }), - delete: publicProcedure - .input(z.object({ eventId: z.string() })) - .mutation(async ({ input, ctx }) => { - 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 - }) - }), - register: publicProcedure - .input(RegisterActionInput.omit({ identifiers: true })) - .mutation((options) => { - return addAction( - { - ...options.input, - identifiers: { - trackingId: getUUID(), - registrationNumber: getUUID() - } - }, - { - eventId: options.input.eventId, - createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId, - token: options.ctx.token - } - ) - }) - }) - }), - events: router({ - get: publicProcedure.output(z.array(EventIndex)).query(getIndexedEvents) - }), - locations: router({ - set: publicProcedure - .use(middleware.isNationalSystemAdminUser) - .input(z.array(Location).min(1)) - .mutation(async (options) => { - await setLocations(options.input) - }), - get: publicProcedure.output(z.array(Location)).query(getLocations) - }) -}) diff --git a/packages/events/src/router/trpc.ts b/packages/events/src/router/trpc.ts new file mode 100644 index 00000000000..663d1e1e175 --- /dev/null +++ b/packages/events/src/router/trpc.ts @@ -0,0 +1,21 @@ +/* + * 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 { initTRPC } from '@trpc/server' +import { Context } from './middleware/middleware' +import superjson from 'superjson' + +export const t = initTRPC.context().create({ + transformer: superjson +}) + +export const router = t.router +export const publicProcedure = t.procedure diff --git a/packages/events/src/router/user/index.ts b/packages/events/src/router/user/index.ts new file mode 100644 index 00000000000..6dac595f443 --- /dev/null +++ b/packages/events/src/router/user/index.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. + */ + +import { router, publicProcedure } from '@events/router/trpc' +import { z } from 'zod' +import { getUsersById } from '@events/service/users/users' + +export const userRouter = router({ + list: publicProcedure + .input(z.array(z.string())) + .query(async ({ input }) => getUsersById(input)) +}) diff --git a/packages/events/src/router/user/user.list.test.ts b/packages/events/src/router/user/user.list.test.ts new file mode 100644 index 00000000000..e68c033e1fd --- /dev/null +++ b/packages/events/src/router/user/user.list.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { createTestClient, setupTestCase } from '@events/tests/utils' + +test('Returns empty list when no ids provided', async () => { + const { user } = await setupTestCase() + const client = createTestClient(user) + + const fetchedEvents = await client.user.list([]) + + expect(fetchedEvents).toEqual([]) +}) + +test('Returns empty list when no ids match', async () => { + const { user } = await setupTestCase() + const client = createTestClient(user) + + const fetchedEvents = await client.user.list(['123-123-123']) + + expect(fetchedEvents).toEqual([]) +}) + +test('Returns user in correct format', async () => { + const { user } = await setupTestCase() + const client = createTestClient(user) + + const fetchedUser = await client.user.list([user.id]) + + expect(fetchedUser).toEqual([ + { + id: user.id, + name: user.name, + systemRole: user.systemRole + } + ]) +}) + +test('Returns multiple users', async () => { + const { user, generator, locations, seed, userMgntDb } = await setupTestCase() + const client = createTestClient(user) + + const usersToCreate = locations.map((location) => + generator.user.create({ primaryOfficeId: location.id }) + ) + + const userIds = [] + for (const user of usersToCreate) { + const userId = (await seed.user(userMgntDb, user))?.id + userIds.push(userId) + } + + const users = await client.user.list(userIds) + + expect(users).toHaveLength(locations.length) +}) 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 00000000000..1e1afbf2ce2 --- /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 00000000000..3f563b7b64d --- /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/events.ts similarity index 72% rename from packages/events/src/service/events.ts rename to packages/events/src/service/events/events.ts index 5a29ac78845..2b7e63a60d6 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events/events.ts @@ -12,19 +12,22 @@ import { ActionInputWithType, EventDocument, + EventIndex, EventInput, FileFieldValue, + getCurrentEventState, isUndeclaredDraft } from '@opencrvs/commons/events' -import { getClient } from '@events/storage/mongodb' +import * as events from '@events/storage/mongodb/events' import { ActionType, getUUID } from '@opencrvs/commons' import { z } from 'zod' -import { deleteEventIndex, indexEvent } from './indexing/indexing' +import { deleteEventIndex, indexEvent } from '@events/service/indexing/indexing' import * as _ from 'lodash' import { TRPCError } from '@trpc/server' -import { getEventConfigurations } from './config/config' -import { deleteFile, fileExists } from './files' +import { getEventConfigurations } from '@events/service/config/config' +import { deleteFile, fileExists } from '@events/service/files' +import { searchForDuplicates } from '@events/service/deduplication/deduplication' export const EventInputWithId = EventInput.extend({ id: z.string() @@ -33,7 +36,7 @@ export const EventInputWithId = EventInput.extend({ export type EventInputWithId = z.infer async function getEventByTransactionId(transactionId: string) { - const db = await getClient() + const db = await events.getClient() const collection = db.collection('events') const document = await collection.findOne({ transactionId }) @@ -48,8 +51,9 @@ class EventNotFoundError extends TRPCError { }) } } + export async function getEventById(id: string) { - const db = await getClient() + const db = await events.getClient() const collection = db.collection('events') const event = await collection.findOne({ id: id }) @@ -65,7 +69,7 @@ export async function deleteEvent( eventId: string, { token }: { token: string } ) { - const db = await getClient() + const db = await events.getClient() const collection = db.collection('events') const event = await collection.findOne({ id: eventId }) @@ -87,7 +91,7 @@ export async function deleteEvent( const { id } = event await collection.deleteOne({ id }) - await deleteEventIndex(id) + await deleteEventIndex(event) return { id } } @@ -134,7 +138,7 @@ export async function createEvent({ return existingEvent } - const db = await getClient() + const db = await events.getClient() const collection = db.collection('events') const now = new Date().toISOString() @@ -178,7 +182,7 @@ export async function addAction( token: string } ) { - const db = await getClient() + const db = await events.getClient() const now = new Date().toISOString() const event = await getEventById(eventId) @@ -231,6 +235,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) @@ -238,7 +310,7 @@ export async function patchEvent(eventInput: EventInputWithId) { throw new EventNotFoundError(eventInput.id) } - const db = await getClient() + const db = await events.getClient() const collection = db.collection('events') const now = new Date().toISOString() diff --git a/packages/events/src/service/files/index.ts b/packages/events/src/service/files/index.ts index 52e1c837304..d1e7a5598e1 100644 --- a/packages/events/src/service/files/index.ts +++ b/packages/events/src/service/files/index.ts @@ -40,6 +40,7 @@ function getFieldDefinitionForActionDataField( logger.error('Failed to find active form configuration', { actionType }) + throw new Error('Failed to find active form configuration') } @@ -64,6 +65,7 @@ export async function presignFilesInEvent(event: EventDocument, token: string) { logger.error('Failed to find configuration for event', { event: event.type }) + throw new Error('Failed to find configuration for event') } @@ -160,6 +162,7 @@ async function presignFiles( status: res.status, text: await res.text() }) + throw new Error('Failed to presign files') } diff --git a/packages/events/src/service/indexing/indexing.test.ts b/packages/events/src/service/indexing/indexing.test.ts index 20d7622cce9..c34f42e3d85 100644 --- a/packages/events/src/service/indexing/indexing.test.ts +++ b/packages/events/src/service/indexing/indexing.test.ts @@ -9,28 +9,29 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ +import { createTestClient, setupTestCase } from '@events/tests/utils' import { getEventIndexName, getOrCreateClient } from '@events/storage/elasticsearch' -import { payloadGenerator } from '@events/tests/generators' -import { createTestClient } from '@events/tests/utils' -import { indexAllEvents } from './indexing' -const client = createTestClient() -const generator = payloadGenerator() +import { indexAllEvents } from './indexing' +import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' test('indexes all records from MongoDB with one function call', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + 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: {} @@ -42,11 +43,14 @@ test('indexes all records from MongoDB with one function call', async () => { }) test('records are automatically indexed when they are created', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + await client.event.create(generator.event.create()) 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 5af143a3d8d..75a513267a2 100644 --- a/packages/events/src/service/indexing/indexing.ts +++ b/packages/events/src/service/indexing/indexing.ts @@ -10,17 +10,20 @@ */ import { + EventConfig, EventDocument, EventIndex, + FieldConfig, getCurrentEventState } from '@opencrvs/commons/events' - import { type estypes } from '@elastic/elasticsearch' -import { getClient } from '@events/storage' +import * as eventsDb from '@events/storage/mongodb/events' import { + getEventAliasName, getEventIndexName, getOrCreateClient } from '@events/storage/elasticsearch' +import { getAllFields } from '@opencrvs/commons' import { Transform } from 'stream' import { z } from 'zod' @@ -33,9 +36,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 +56,82 @@ 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' || + 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' } + } + } + } + + return assertNever(field) +} + +const assertNever = (n: never): never => { + throw new Error('Should never happen') } -export async function indexAllEvents() { - const mongoClient = await getClient() +function formFieldsToDataMapping(fields: FieldConfig[]) { + return fields.reduce((acc, field) => { + return { + ...acc, + [field.id]: getElasticsearchMappingForType(field) + } + }, {}) +} + +export async function indexAllEvents(eventConfiguration: EventConfig) { + const mongoClient = await eventsDb.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 +147,7 @@ export async function indexAllEvents() { datasource: stream.pipe(transformedStreamData), onDocument: (doc: EventIndex) => ({ index: { - _index: getEventIndexName(), + _index: indexName, _id: doc.id } }), @@ -93,9 +157,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 +170,12 @@ export async function indexEvent(event: EventDocument) { }) } -export async function deleteEventIndex(eventId: string) { +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 +186,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/service/locations/locations.ts b/packages/events/src/service/locations/locations.ts index 37fbf077e87..24a1c9ebead 100644 --- a/packages/events/src/service/locations/locations.ts +++ b/packages/events/src/service/locations/locations.ts @@ -11,10 +11,11 @@ import { z } from 'zod' import * as _ from 'lodash' -import { getClient } from '@events/storage' +import * as events from '@events/storage/mongodb/events' export const Location = z.object({ id: z.string(), + externalId: z.string().nullable(), name: z.string(), partOf: z.string().nullable() }) @@ -31,7 +32,7 @@ export type Location = z.infer * @param incomingLocations - Locations to be set */ export async function setLocations(incomingLocations: Array) { - const db = await getClient() + const db = await events.getClient() const currentLocations = await db.collection('locations').find().toArray() const [locationsToKeep, locationsToRemove] = _.partition( @@ -58,7 +59,7 @@ export async function setLocations(incomingLocations: Array) { } export const getLocations = async () => { - const db = await getClient() + const db = await events.getClient() return db.collection('locations').find().toArray() } diff --git a/packages/events/src/service/users/users.ts b/packages/events/src/service/users/users.ts new file mode 100644 index 00000000000..8659466a71e --- /dev/null +++ b/packages/events/src/service/users/users.ts @@ -0,0 +1,43 @@ +/* + * 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 * as userMgntDb from '@events/storage/mongodb/user-mgnt' +import { ResolvedUser } from '@opencrvs/commons' +import { ObjectId } from 'mongodb' + +export const getUsersById = async (ids: string[]) => { + const db = await userMgntDb.getClient() + + if (ids.length === 0) { + return [] + } + + const results = await db + .collection<{ + _id: ObjectId + name: ResolvedUser['name'] + systemRole: string + }>('users') + .find({ + _id: { + $in: ids + .filter((id) => ObjectId.isValid(id)) + .map((id) => new ObjectId(id)) + } + }) + .toArray() + + return results.map((user) => ({ + id: user._id.toString(), + name: user.name, + systemRole: user.systemRole + })) +} diff --git a/packages/events/src/storage/__mocks__/elasticsearch.ts b/packages/events/src/storage/__mocks__/elasticsearch.ts index 9ebbaa1c6c3..1e5be9ab35c 100644 --- a/packages/events/src/storage/__mocks__/elasticsearch.ts +++ b/packages/events/src/storage/__mocks__/elasticsearch.ts @@ -11,7 +11,10 @@ import * as elasticsearch from '@elastic/elasticsearch' import { inject, vi } from 'vitest' +/** @knipignore */ export const getEventIndexName = vi.fn() +/** @knipignore */ +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 b3f0ad44dfc..32f30f3829e 100644 --- a/packages/events/src/storage/elasticsearch.ts +++ b/packages/events/src/storage/elasticsearch.ts @@ -13,15 +13,22 @@ import { env } from '@events/environment' let client: elasticsearch.Client -export const getEventIndexName = () => 'events' - export const getOrCreateClient = () => { if (!client) { client = new elasticsearch.Client({ - node: env.ES_HOST + node: env.ES_URL }) + return client } return client } + +export function getEventAliasName() { + return `events` +} + +export function getEventIndexName(eventType: string) { + return `events_${eventType}`.toLowerCase() +} diff --git a/packages/events/src/storage/__mocks__/mongodb.ts b/packages/events/src/storage/mongodb/__mocks__/events.ts similarity index 93% rename from packages/events/src/storage/__mocks__/mongodb.ts rename to packages/events/src/storage/mongodb/__mocks__/events.ts index 0273cbf2b6e..c3ed65b2f11 100644 --- a/packages/events/src/storage/__mocks__/mongodb.ts +++ b/packages/events/src/storage/mongodb/__mocks__/events.ts @@ -20,7 +20,7 @@ export async function resetServer() { export async function getClient() { if (!client) { - client = new MongoClient(inject('MONGO_URI')) + client = new MongoClient(inject('EVENTS_MONGO_URI')) } await client.connect() diff --git a/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts b/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts new file mode 100644 index 00000000000..9b449f05f32 --- /dev/null +++ b/packages/events/src/storage/mongodb/__mocks__/user-mgnt.ts @@ -0,0 +1,29 @@ +/* + * 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 { MongoClient } from 'mongodb' +import { inject } from 'vitest' + +let client: MongoClient +let databaseName = 'user-mgnt_' + Date.now() + +export async function resetServer() { + databaseName = 'user-mgnt_' + Date.now() +} + +export async function getClient() { + if (!client) { + client = new MongoClient(inject('USER_MGNT_MONGO_URI')) + } + + await client.connect() + + return client.db(databaseName) +} diff --git a/packages/events/src/storage/mongodb.ts b/packages/events/src/storage/mongodb/events.ts similarity index 72% rename from packages/events/src/storage/mongodb.ts rename to packages/events/src/storage/mongodb/events.ts index e2cad2c9ccd..bb6844b2cc7 100644 --- a/packages/events/src/storage/mongodb.ts +++ b/packages/events/src/storage/mongodb/events.ts @@ -12,12 +12,13 @@ import { env } from '@events/environment' import { MongoClient } from 'mongodb' -const url = env.MONGO_URL +const url = env.EVENTS_MONGO_URL const client = new MongoClient(url) export async function getClient() { await client.connect() - const db = client.db('events') - return db + // Providing the database name is not necessary, it will read it from the connection string. + // e2e-environment uses different name from deployment to deployment, so we can't hardcode it. + return client.db() } diff --git a/packages/events/src/storage/mongodb/user-mgnt.ts b/packages/events/src/storage/mongodb/user-mgnt.ts new file mode 100644 index 00000000000..35fa08e4f1a --- /dev/null +++ b/packages/events/src/storage/mongodb/user-mgnt.ts @@ -0,0 +1,24 @@ +/* + * 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 { env } from '@events/environment' +import { MongoClient } from 'mongodb' + +const url = env.USER_MGNT_MONGO_URL +const client = new MongoClient(url) + +export async function getClient() { + await client.connect() + + // Providing the database name is not necessary, it will read it from the connection string. + // e2e-environment uses different name from deployment to deployment, so we can't hardcode it. + return client.db() +} diff --git a/packages/events/src/tests/generators.ts b/packages/events/src/tests/generators.ts index 11a90d16bf6..b0fe05cf985 100644 --- a/packages/events/src/tests/generators.ts +++ b/packages/events/src/tests/generators.ts @@ -13,10 +13,30 @@ import { DeclareActionInput, EventInput, getUUID, - ActionType + ActionType, + ValidateActionInput } from '@opencrvs/commons' import { Location } from '@events/service/locations/locations' +import { Db } from 'mongodb' +type Name = { + use: string + given: string[] + family: string +} + +export type CreatedUser = { + id: string + primaryOfficeId: string + systemRole: string + name: Array +} + +type CreateUser = { + primaryOfficeId: string + systemRole?: string + name?: Array +} /** * @returns a payload generator for creating events and actions with sensible defaults. */ @@ -40,10 +60,28 @@ export function payloadGenerator() { transactionId: input?.transactionId ?? getUUID(), data: input?.data ?? {}, eventId + }), + validate: ( + eventId: string, + input: Partial> = {} + ) => ({ + type: ActionType.VALIDATE, + transactionId: input?.transactionId ?? getUUID(), + data: input?.data ?? {}, + duplicates: [], + eventId }) } } + const user = { + create: (input: CreateUser) => ({ + systemRole: input?.systemRole ?? 'REGISTRATION_AGENT', + name: input?.name ?? [{ use: 'en', family: 'Doe', given: ['John'] }], + primaryOfficeId: input.primaryOfficeId + }) + } + const locations = { /** Create test data by providing count or desired locations */ set: (input: Array> | number) => { @@ -51,6 +89,7 @@ export function payloadGenerator() { return Array.from({ length: input }).map((_, i) => ({ id: getUUID(), name: `Location name ${i}`, + externalId: getUUID(), partOf: null })) } @@ -58,10 +97,35 @@ export function payloadGenerator() { return input.map((location, i) => ({ id: location.id ?? getUUID(), name: location.name ?? `Location name ${i}`, + externalId: location.externalId ?? getUUID(), partOf: null })) } } - return { event, locations } + return { event, locations, user } +} + +/** + * Helper utility to seed data into the database. + * Use with payloadGenerator for creating test data. + */ +export function seeder() { + const seedUser = async (db: Db, user: Omit) => { + const createdUser = await db.collection('users').insertOne(user) + + return { + primaryOfficeId: user.primaryOfficeId, + name: user.name, + systemRole: user.systemRole, + id: createdUser.insertedId.toString() + } + } + const seedLocations = (db: Db, locations: Location[]) => + db.collection('locations').insertMany(locations) + + return { + user: seedUser, + locations: seedLocations + } } diff --git a/packages/events/src/tests/global-setup.ts b/packages/events/src/tests/global-setup.ts index 28861472868..666fcdcbe0c 100644 --- a/packages/events/src/tests/global-setup.ts +++ b/packages/events/src/tests/global-setup.ts @@ -15,7 +15,8 @@ export type { ProvidedContext } from 'vitest' declare module 'vitest' { export interface ProvidedContext { - MONGO_URI: string + EVENTS_MONGO_URI: string + USER_MGNT_MONGO_URI: string ELASTICSEARCH_URI: string } } @@ -33,17 +34,20 @@ async function setupServer() { } export default async function setup({ provide }: any) { - const [mongod, es] = await Promise.all([ - await MongoMemoryServer.create(), - await setupServer() - ]) - const uri = mongod.getUri() + const eventsMongoD = await MongoMemoryServer.create() + const userMgntMongoD = await MongoMemoryServer.create() + const es = await setupServer() + + const eventsURI = eventsMongoD.getUri() + const userMgntURI = userMgntMongoD.getUri() provide('ELASTICSEARCH_URI', `${es.getHost()}:${es.getMappedPort(9200)}`) - provide('MONGO_URI', uri) + provide('EVENTS_MONGO_URI', eventsURI) + provide('USER_MGNT_MONGO_URI', userMgntURI) return async () => { await es.stop() - await mongod.stop() + await eventsMongoD.stop() + await userMgntMongoD.stop() } } diff --git a/packages/events/src/tests/setup.ts b/packages/events/src/tests/setup.ts index a79d60a86d2..942b11175d8 100644 --- a/packages/events/src/tests/setup.ts +++ b/packages/events/src/tests/setup.ts @@ -8,14 +8,18 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { resetServer as resetMongoServer } from '@events/storage/__mocks__/mongodb' +import { resetServer as resetEventsMongoServer } from '@events/storage/mongodb/__mocks__/events' +import { resetServer as resetUserMgntMongoServer } from '@events/storage/mongodb/__mocks__/user-mgnt' 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/events') +vi.mock('@events/storage/mongodb/user-mgnt') -vi.mock('@events/storage/mongodb') vi.mock('@events/storage/elasticsearch') vi.mock('@events/service/config/config', () => ({ getEventConfigurations: () => @@ -25,17 +29,24 @@ 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() +async function resetESServer() { + 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 () => { - return Promise.all([resetMongoServer(), resetServer()]) -}) +beforeEach(() => + Promise.all([ + resetEventsMongoServer(), + resetUserMgntMongoServer(), + resetESServer() + ]) +) beforeAll(() => mswServer.listen({ diff --git a/packages/events/src/tests/utils.ts b/packages/events/src/tests/utils.ts index 4b1a4f52b4c..35b256919f8 100644 --- a/packages/events/src/tests/utils.ts +++ b/packages/events/src/tests/utils.ts @@ -9,28 +9,33 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { appRouter, t } from '@events/router' +import { appRouter } from '@events/router' +import { t } from '@events/router/trpc' import * as jwt from 'jsonwebtoken' import { readFileSync } from 'fs' import { join } from 'path' import { Scope, userRoleScopes } from '@opencrvs/commons' +import { CreatedUser, payloadGenerator } from './generators' +import * as events from '@events/storage/mongodb/__mocks__/events' +import * as userMgnt from '@events/storage/mongodb/__mocks__/user-mgnt' +import { seeder } from '@events/tests/generators' const { createCallerFactory } = t -export function createTestClient(scopes?: Scope[]) { +export function createTestClient(user: CreatedUser, scopes?: Scope[]) { const createCaller = createCallerFactory(appRouter) - const token = createTestToken(scopes) + const token = createTestToken(user.id, scopes) const caller = createCaller({ - user: { id: '1', primaryOfficeId: '123' }, + user: { id: user.id, primaryOfficeId: user.primaryOfficeId }, token }) return caller } -const createTestToken = (scopes?: Scope[]) => +const createTestToken = (userId: string, scopes?: Scope[]) => jwt.sign( - { scope: scopes ?? userRoleScopes.REGISTRATION_AGENT }, + { scope: scopes ?? userRoleScopes.REGISTRATION_AGENT, sub: userId }, readFileSync(join(__dirname, './cert.key')), { algorithm: 'RS256', @@ -38,3 +43,32 @@ const createTestToken = (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. + */ +export const setupTestCase = async () => { + const generator = payloadGenerator() + const eventsDb = await events.getClient() + const userMgntDb = await userMgnt.getClient() + + const seed = seeder() + const locations = generator.locations.set(5) + await seed.locations(eventsDb, locations) + + const user = await seed.user( + userMgntDb, + generator.user.create({ + primaryOfficeId: locations[0].id + }) + ) + + return { + locations, + user, + eventsDb, + userMgntDb, + seed, + generator + } +} diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json index 241cda5dd4f..70ec3d00fb0 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 ed19c3dc2b3..04501dc28c1 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 287b2a2fc45..3c39281d7ce 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 0fe1033ee48..a6f480672be 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 05ac70d02f8..e539e1df39d 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.14-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 00000000000..a15424befdf --- /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 c6173ecaf09..936f0ba0722 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 } @@ -115,6 +117,95 @@ export function field(fieldId: string) { } }, required: ['$form', '$now'] + }), + isEqualTo: (value: string) => ({ + type: 'object', + properties: { + $form: { + type: 'object', + properties: { + [fieldId]: { + const: value + } + }, + required: [fieldId] + } + }, + required: ['$form'] + }), + isInArray: (values: string[]) => ({ + type: 'object', + properties: { + $form: { + type: 'object', + properties: { + [fieldId]: { + enum: values + } + }, + required: [fieldId] + } + }, + required: ['$form'] + }), + isNotInArray: (values: string[]) => ({ + type: 'object', + properties: { + $form: { + type: 'object', + properties: { + [fieldId]: { + not: { + enum: values + } + } + }, + required: [fieldId] + } + }, + required: ['$form'] + }), + isUndefinedOrInArray: (values: string[]) => ({ + type: 'object', + properties: { + $form: { + type: 'object', + anyOf: [ + { + required: [fieldId], + properties: { + [fieldId]: { + enum: values + } + } + }, + { not: { required: [fieldId] } } + ] + } + }, + required: ['$form'] + }), + isUndefinedOrNotInArray: (values: string[]) => ({ + type: 'object', + properties: { + $form: { + type: 'object', + anyOf: [ + { + required: [fieldId], + properties: { + [fieldId]: { + not: { + enum: values + } + } + } + }, + { not: { required: [fieldId] } } + ] + } + }, + required: ['$form'] }) } } diff --git a/packages/webhooks/tsconfig.json b/packages/webhooks/tsconfig.json index e0fe6589e6d..9462ff7f59f 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 ceef8c93344..18fc70f1a1b 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 2155a0b464e..66bb9bb6e23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12964,6 +12964,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" @@ -23623,16 +23628,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== @@ -23792,7 +23788,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== @@ -23813,13 +23809,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" @@ -26138,7 +26127,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== @@ -26165,15 +26154,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"