diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd48d71ad2..1974d77c695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,14 @@ HTTP input now accepts `field('..')` references in the HTTP body definition. #### Jurisdiction - Elasticsearch now stores location IDs as a full administrative hierarchy, with the leaf representing the actual event location. This enables searching events by any jurisdiction level (district, province, office, health facility etc.). +- Added configurable placeOfEvent in EventConfig, allowing multiple location fields to be defined, with only one becoming the active place of event per document (based on conditionals), enabling jurisdiction-specific search by event location (e.g., birth location, child’s home address, death location). ### Improvements - Refactor the tRPC context to allow defining public procedures that don't require authentication. - Remove legacy mongo migration status outputs and skip typecheck which reduced the migration service startup time by 66%. - The postgres migration files now get restored to their original state (i.e. without the environment variables being replaced) regardless of the migration passing or not +- Added experimental ALPHA_HIDDEN form field type, allowing configurable default/derived values and conditional inclusion in form submissions. ## 1.9.2 diff --git a/packages/client/src/v2-events/components/forms/FormFieldGenerator/FormSectionComponent.tsx b/packages/client/src/v2-events/components/forms/FormFieldGenerator/FormSectionComponent.tsx index e9a9c528c06..1b6952f5d67 100644 --- a/packages/client/src/v2-events/components/forms/FormFieldGenerator/FormSectionComponent.tsx +++ b/packages/client/src/v2-events/components/forms/FormFieldGenerator/FormSectionComponent.tsx @@ -240,9 +240,27 @@ export function FormSectionComponent({ ) const firstNonFalsyValue = compact( - referencesToOtherFields.map((reference) => - resolveFieldReferenceValue(reference, fieldValues) - ) + referencesToOtherFields.map((reference) => { + const referenceFieldConfig = allFieldsWithDotSeparator.find( + (field: FieldConfig) => field.id === reference.$$field + ) + + if (!referenceFieldConfig) { + return undefined + } + + const isReferenceVisible = isFieldVisible( + referenceFieldConfig, + makeFormikFieldIdsOpenCRVSCompatible(fieldValues), + validatorContext + ) + + if (!isReferenceVisible || !listenerFieldConfig) { + return undefined + } + + return resolveFieldReferenceValue(reference, fieldValues) + }) )[0] if (firstNonFalsyValue) { @@ -261,7 +279,7 @@ export function FormSectionComponent({ return }, - [allFieldsWithDotSeparator, systemVariables] + [allFieldsWithDotSeparator, systemVariables, validatorContext] ) const onFieldValueChange = useCallback( diff --git a/packages/client/src/v2-events/components/forms/FormFieldGenerator/GeneratedInputField.tsx b/packages/client/src/v2-events/components/forms/FormFieldGenerator/GeneratedInputField.tsx index 16dd6d771cc..131ac1d18cc 100644 --- a/packages/client/src/v2-events/components/forms/FormFieldGenerator/GeneratedInputField.tsx +++ b/packages/client/src/v2-events/components/forms/FormFieldGenerator/GeneratedInputField.tsx @@ -62,6 +62,7 @@ import { isIdReaderFieldType, isQrReaderFieldType, isLoaderFieldType, + isHiddenFieldType, isAgeFieldType } from '@opencrvs/commons/client' import { TextArea } from '@opencrvs/components/lib/TextArea' @@ -100,6 +101,7 @@ import { IdReader } from '@client/v2-events/features/events/registered-fields/Id import { QrReader } from '@client/v2-events/features/events/registered-fields/QrReader' import { QueryParamReader } from '@client/v2-events/features/events/registered-fields/QueryParamReader' import { Loader } from '@client/v2-events/features/events/registered-fields/Loader' +import { Hidden } from '@client/v2-events/features/events/registered-fields/Hidden' import { makeFormFieldIdFormikCompatible, makeFormikFieldIdOpenCRVSCompatible @@ -800,6 +802,15 @@ export const GeneratedInputField = React.memo( ) } + if (isHiddenFieldType(field)) { + return ( + + ) + } + throw new Error(`Unsupported field ${JSON.stringify(fieldDefinition)}`) } ) diff --git a/packages/client/src/v2-events/features/events/Search/SearchResult.tsx b/packages/client/src/v2-events/features/events/Search/SearchResult.tsx index 4c1e86f8e43..d572436d43e 100644 --- a/packages/client/src/v2-events/features/events/Search/SearchResult.tsx +++ b/packages/client/src/v2-events/features/events/Search/SearchResult.tsx @@ -52,6 +52,7 @@ const COLUMNS = { ICON_WITH_NAME_EVENT: 'iconWithNameEvent', EVENT: 'type', DATE_OF_EVENT: 'dateOfEvent', + PLACE_OF_EVENT: 'placeOfEvent', SENT_FOR_REVIEW: 'sentForReview', SENT_FOR_UPDATES: 'sentForUpdates', SENT_FOR_APPROVAL: 'sentForApproval', @@ -98,6 +99,9 @@ function changeSortedColumn( case COLUMNS.DATE_OF_EVENT: newSortedCol = COLUMNS.DATE_OF_EVENT break + case COLUMNS.PLACE_OF_EVENT: + newSortedCol = COLUMNS.PLACE_OF_EVENT + break case COLUMNS.SENT_FOR_REVIEW: newSortedCol = COLUMNS.SENT_FOR_REVIEW break diff --git a/packages/client/src/v2-events/features/events/actions/correct/request/Summary/DeclarationComparisonTable.tsx b/packages/client/src/v2-events/features/events/actions/correct/request/Summary/DeclarationComparisonTable.tsx index 77092afb813..c910a4b7893 100644 --- a/packages/client/src/v2-events/features/events/actions/correct/request/Summary/DeclarationComparisonTable.tsx +++ b/packages/client/src/v2-events/features/events/actions/correct/request/Summary/DeclarationComparisonTable.tsx @@ -16,7 +16,7 @@ import { EventConfig, EventDocument, EventState, - getAcceptedActions, + FieldTypesToHideInReview, getDeclaration, isFieldDisplayedOnReview, ValidatorContext @@ -199,6 +199,12 @@ function DeclarationComparisonTableComponent({ <> {declarationConfig.pages.map((page) => { const changedFields = page.fields + .filter( + ({ type }) => + !FieldTypesToHideInReview.some( + (typeToHide) => type === typeToHide + ) + ) .filter((field) => isFieldDisplayedOnReview(field, latestDeclaration, validatorContext) ) diff --git a/packages/client/src/v2-events/features/events/registered-fields/Hidden.interaction.stories.tsx b/packages/client/src/v2-events/features/events/registered-fields/Hidden.interaction.stories.tsx new file mode 100644 index 00000000000..166c002572f --- /dev/null +++ b/packages/client/src/v2-events/features/events/registered-fields/Hidden.interaction.stories.tsx @@ -0,0 +1,67 @@ +/* + * 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 type { Meta, StoryObj } from '@storybook/react' +import React from 'react' +import styled from 'styled-components' +import { expect, within } from '@storybook/test' +import { TRPCProvider } from '@client/v2-events/trpc' +import { Hidden } from './Hidden' + +const meta: Meta = { + title: 'Inputs/Hidden', + component: Hidden.Input, + args: {}, + decorators: [ + (Story) => ( + + + + ) + ] +} + +export default meta + +const Box = styled.div` + border: 2px solid #000; + padding: 1rem; +` + +function HiddenComponentBox({ children }: { children: React.ReactNode }) { + return ( +
+

+ { + 'Inside the box there is a hidden component, which is not rendered in the UI, but is present in the DOM' + } +

+ {children} +
+ ) +} + +export const HiddenInput: StoryObj = { + args: { + id: 'hidden-input', + value: 'hidden value' + }, + render: (args) => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const hiddenInput = await canvas.findByTestId('text__hidden-input') + await expect(hiddenInput).toBeInTheDocument() + await expect(hiddenInput).toHaveValue('hidden value') + } +} diff --git a/packages/client/src/v2-events/features/events/registered-fields/Hidden.tsx b/packages/client/src/v2-events/features/events/registered-fields/Hidden.tsx new file mode 100644 index 00000000000..c7d2f2882f9 --- /dev/null +++ b/packages/client/src/v2-events/features/events/registered-fields/Hidden.tsx @@ -0,0 +1,31 @@ +/* + * 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 React from 'react' + +function HiddenInput({ id, value }: { id: string; value?: string }) { + return ( + + ) +} + +export const Hidden = { + Input: HiddenInput, + stringify: (value: string | undefined) => { + return value?.toString() || '' + } +} diff --git a/packages/commons/src/events/EventConfig.ts b/packages/commons/src/events/EventConfig.ts index f5d2aafb70e..0d71ae04923 100644 --- a/packages/commons/src/events/EventConfig.ts +++ b/packages/commons/src/events/EventConfig.ts @@ -15,7 +15,6 @@ import { TranslationConfig } from './TranslationConfig' import { AdvancedSearchConfig, EventFieldId } from './AdvancedSearchConfig' import { findAllFields, getDeclarationFields } from './utils' import { DeclarationFormConfig } from './FormConfig' - import { FieldType } from './FieldType' import { FieldReference } from './FieldConfig' import { FlagConfig, InherentFlags } from './Flag' @@ -35,6 +34,9 @@ export const EventConfig = z dateOfEvent: FieldReference.optional().describe( 'Reference to the field capturing the date of the event (e.g. date of birth). Defaults to the event creation date if unspecified.' ), + placeOfEvent: FieldReference.optional().describe( + 'Reference to the field capturing the place of the event (e.g. place of birth). Defaults to the meta.createdAtLocation if unspecified.' + ), title: TranslationConfig.describe( 'Title template for the singular event, supporting variables (e.g. "{applicant.name.firstname} {applicant.name.surname}").' ), @@ -134,6 +136,20 @@ export const EventConfig = z } } + if (event.placeOfEvent) { + const eventPlaceFieldId = getDeclarationFields(event).find( + ({ id }) => id === event.placeOfEvent?.$$field + ) + if (!eventPlaceFieldId) { + ctx.addIssue({ + code: 'custom', + message: `Place of event field id must match a field id in the event.declaration fields. + Invalid place of event field ID for event ${event.id}: ${event.placeOfEvent.$$field}`, + path: ['placeOfEvent'] + }) + } + } + const isInherentFlag = (value: unknown): value is InherentFlags => Object.values(InherentFlags).includes(value as InherentFlags) diff --git a/packages/commons/src/events/EventMetadata.ts b/packages/commons/src/events/EventMetadata.ts index 7fa59c4b0ec..95b357bd761 100644 --- a/packages/commons/src/events/EventMetadata.ts +++ b/packages/commons/src/events/EventMetadata.ts @@ -95,6 +95,7 @@ export const EventMetadata = z.object({ .datetime() .describe('The timestamp when the event was first created and saved.'), dateOfEvent: ZodDate.nullish(), + placeOfEvent: CreatedAtLocation, createdBy: z.string().describe('ID of the user who created the event.'), createdByUserType: z .enum(['user', 'system']) @@ -147,6 +148,7 @@ export const EventMetadataKeysArray = [ 'status', 'createdAt', 'dateOfEvent', + 'placeOfEvent', 'createdBy', 'createdByUserType', 'updatedByUserRole', @@ -192,6 +194,11 @@ export const eventMetadataLabelMap: Record< defaultMessage: 'Date of Event', description: 'Date of Event' }, + 'event.placeOfEvent': { + id: 'event.placeOfEvent.label', + defaultMessage: 'Place of Event', + description: 'Place of Event' + }, 'event.createdAtLocation': { id: 'event.createdAtLocation.label', defaultMessage: 'Location', diff --git a/packages/commons/src/events/FieldConfig.ts b/packages/commons/src/events/FieldConfig.ts index 2cbb8f8b510..db2ab5b747a 100644 --- a/packages/commons/src/events/FieldConfig.ts +++ b/packages/commons/src/events/FieldConfig.ts @@ -846,6 +846,16 @@ const LoaderField = BaseField.extend({ export type LoaderField = z.infer +const HiddenField = BaseField.extend({ + type: z.literal(FieldType.ALPHA_HIDDEN), + required: z.boolean().default(false).optional(), + defaultValue: TextValue.optional() +}).describe( + 'A non-interactive, hidden field that only hold a value in the form' +) + +export type HiddenField = z.infer + export const FieldConfig = z .discriminatedUnion('type', [ Address, @@ -886,7 +896,8 @@ export const FieldConfig = z IdReaderField, QueryParamReaderField, LoaderField, - SearchField + SearchField, + HiddenField ]) .meta({ description: 'Form field configuration', diff --git a/packages/commons/src/events/FieldType.ts b/packages/commons/src/events/FieldType.ts index d289139b972..de161d89fdb 100644 --- a/packages/commons/src/events/FieldType.ts +++ b/packages/commons/src/events/FieldType.ts @@ -50,7 +50,8 @@ export const FieldType = { QUERY_PARAM_READER: 'QUERY_PARAM_READER', QR_READER: 'QR_READER', ID_READER: 'ID_READER', - LOADER: 'LOADER' + LOADER: 'LOADER', + ALPHA_HIDDEN: 'ALPHA_HIDDEN' } as const /** @@ -86,5 +87,6 @@ export const FieldTypesToHideInReview = [ FieldType.LOADER, FieldType.HTTP, FieldType.QUERY_PARAM_READER, - FieldType.DATA + FieldType.DATA, + FieldType.ALPHA_HIDDEN ] diff --git a/packages/commons/src/events/FieldTypeMapping.ts b/packages/commons/src/events/FieldTypeMapping.ts index bc534c46ecd..2a0123f7ac3 100644 --- a/packages/commons/src/events/FieldTypeMapping.ts +++ b/packages/commons/src/events/FieldTypeMapping.ts @@ -50,7 +50,8 @@ import { QrReaderField, IdReaderField, LoaderField, - AgeField + AgeField, + HiddenField } from './FieldConfig' import { FieldType } from './FieldType' import { @@ -159,6 +160,7 @@ export function mapFieldTypeToZod(field: FieldConfig, actionType?: ActionType) { case FieldType.VERIFICATION_STATUS: case FieldType.ID: case FieldType.LOADER: + case FieldType.ALPHA_HIDDEN: schema = field.required ? NonEmptyTextValue : TextValue break case FieldType.NUMBER: @@ -250,6 +252,7 @@ export function mapFieldTypeToEmptyValue(field: FieldConfig) { case FieldType.QR_READER: case FieldType.ID_READER: case FieldType.LOADER: + case FieldType.ALPHA_HIDDEN: return null case FieldType.ADDRESS: return { @@ -555,6 +558,13 @@ export const isLoaderFieldType = (field: { return field.config.type === FieldType.LOADER } +export const isHiddenFieldType = (field: { + config: FieldConfig + value: FieldValue | FieldUpdateValue +}): field is { value: undefined; config: HiddenField } => { + return field.config.type === FieldType.ALPHA_HIDDEN +} + export type NonInteractiveFieldType = | Divider | PageHeader diff --git a/packages/commons/src/events/FieldValue.ts b/packages/commons/src/events/FieldValue.ts index 276b58837bf..875628e8d6f 100644 --- a/packages/commons/src/events/FieldValue.ts +++ b/packages/commons/src/events/FieldValue.ts @@ -37,6 +37,7 @@ import { */ export const TextValue = z.string() +export const HiddenFieldValue = z.string() export const NonEmptyTextValue = TextValue.min(1) export const DateValue = z.iso.date().describe('Date in the format YYYY-MM-DD') @@ -229,6 +230,7 @@ export type FieldUpdateValue = | z.infer | z.infer | z.infer + | z.infer // All schemas are tagged using .describe() so we can identify them later // inside safeUnion(). The tag name should match PRIORITY_ORDER. @@ -247,7 +249,8 @@ export const FieldUpdateValue: z.ZodType = safeUnion([ DataFieldValue.describe('DataFieldValue'), NameFieldUpdateValue.describe('NameFieldUpdateValue'), HttpFieldUpdateValue.describe('HttpFieldUpdateValue'), - QueryParamReaderFieldUpdateValue.describe('QueryParamReaderFieldUpdateValue') + QueryParamReaderFieldUpdateValue.describe('QueryParamReaderFieldUpdateValue'), + HiddenFieldValue.describe('HiddenFieldValue') ]) /** diff --git a/packages/commons/src/events/state/index.test.ts b/packages/commons/src/events/state/index.test.ts index 403fbef10d1..60b37830fd5 100644 --- a/packages/commons/src/events/state/index.test.ts +++ b/packages/commons/src/events/state/index.test.ts @@ -193,7 +193,7 @@ describe('getCurrentEventState()', () => { createdAt: '2023-01-01T00:00:00.000Z', createdBy: 'user1', createdByUserType: TokenUserType.enum.user, - createdAtLocation: 'location1' as UUID, + createdAtLocation: getUUID(), createdBySignature: '/ocrvs/signature.png', createdByRole: 'FIELD_AGENT' } @@ -208,7 +208,7 @@ describe('getCurrentEventState()', () => { createdAt: '2023-02-01T00:00:00.000Z', createdBy: 'user1', createdByUserType: TokenUserType.enum.user, - createdAtLocation: 'location1' as UUID, + createdAtLocation: getUUID(), createdBySignature: '/ocrvs/signature.png', createdByRole: 'FIELD_AGENT' } @@ -312,6 +312,7 @@ describe('getCurrentEventState()', () => { declareRequestAction.declaration ) as EventState, dateOfEvent: event.createdAt.split('T')[0], + placeOfEvent: createAction.createdAtLocation, flags: [InherentFlags.PENDING_CERTIFICATION, 'validated'], potentialDuplicates: [], legalStatuses: { @@ -350,7 +351,7 @@ describe('getCurrentEventState()', () => { createdByUserType: TokenUserType.enum.user, createdBy: 'user1', createdBySignature: '/ocrvs/signature.png', - createdAtLocation: 'location1' as UUID, + createdAtLocation: getUUID(), createdByRole: 'FIELD_AGENT' } }) @@ -365,7 +366,7 @@ describe('getCurrentEventState()', () => { createdByUserType: TokenUserType.enum.user, createdBy: 'user1', createdBySignature: '/ocrvs/signature.png', - createdAtLocation: 'location1' as UUID, + createdAtLocation: getUUID(), createdByRole: 'FIELD_AGENT' } }) @@ -432,6 +433,7 @@ describe('getCurrentEventState()', () => { updatedAtLocation: registerAcceptAction.createdAtLocation, declaration: deepDropNulls(declareAcceptAction.declaration) as EventState, dateOfEvent: event.createdAt.split('T')[0], + placeOfEvent: createAction.createdAtLocation, flags: [InherentFlags.PENDING_CERTIFICATION, 'validated'], potentialDuplicates: [], legalStatuses: { diff --git a/packages/commons/src/events/state/index.ts b/packages/commons/src/events/state/index.ts index 23a96b15763..8a8f24be3b4 100644 --- a/packages/commons/src/events/state/index.ts +++ b/packages/commons/src/events/state/index.ts @@ -20,7 +20,7 @@ import { } from '../ActionDocument' import { EventDocument } from '../EventDocument' import { EventIndex } from '../EventIndex' -import { EventStatus, ZodDate } from '../EventMetadata' +import { EventMetadata, EventStatus, ZodDate } from '../EventMetadata' import { Draft } from '../Draft' import { aggregateActionDeclarations, @@ -37,6 +37,7 @@ import { FullDocumentPath, FullDocumentUrl } from '../../documents' +import { AddressFieldValue, AddressType } from '../CompositeFieldValue' export function getStatusFromActions(actions: Array) { return actions @@ -162,6 +163,55 @@ export function resolveDateOfEvent( return parsedDate.success ? parsedDate.data : undefined } +export const DEFAULT_PLACE_OF_EVENT_PROPERTY = + 'createdAtLocation' satisfies keyof EventMetadata + +/** + * + * @param value value to parse + * @param oldValue fallback value, its needed when the value is invalid but we want + * to keep the previous valid value, since this can be used mutliple times in one flow + * @returns successfully parsed UUID or fallback value + */ +function getParsedUUID(value: unknown, oldValue?: UUID) { + const parsed = UUID.safeParse(value) + return parsed.success ? parsed.data : oldValue +} + +export function resolvePlaceOfEvent( + eventMetadata: { + createdAtLocation?: UUID | undefined | null + }, + declaration: EventState, + config: EventConfig +): UUID | undefined | null { + let placeOfEvent: UUID | undefined | null = getParsedUUID( + eventMetadata[DEFAULT_PLACE_OF_EVENT_PROPERTY] + ) + + if (config.placeOfEvent) { + const addressFieldValue = AddressFieldValue.safeParse( + declaration[config.placeOfEvent.$$field] + ) + if ( + addressFieldValue.success && + addressFieldValue.data.addressType === AddressType.DOMESTIC && + addressFieldValue.data.administrativeArea + ) { + placeOfEvent = getParsedUUID( + addressFieldValue.data.administrativeArea, + placeOfEvent + ) + } else { + placeOfEvent = getParsedUUID( + declaration[config.placeOfEvent.$$field], + placeOfEvent + ) + } + } + return placeOfEvent +} + export function extractPotentialDuplicatesFromActions( actions: Action[] ): PotentialDuplicate[] { @@ -181,6 +231,7 @@ export function extractPotentialDuplicatesFromActions( /** * NOTE: This function should not run field validations. It should return the state based on the actions, without considering context (users, roles, permissions, etc). +createdAtLocation: CreatedAtLocation * * If you update this function, please ensure @EventIndex type is updated accordingly. * In most cases, you won't need to add new parameters to this function. Discuss with the team before doing so. @@ -236,6 +287,11 @@ export function getCurrentEventState( trackingId: event.trackingId, updatedByUserRole: requestActionMetadata.createdByRole, dateOfEvent: resolveDateOfEvent(event, declaration, config), + placeOfEvent: resolvePlaceOfEvent( + { createdAtLocation: creationAction.createdAtLocation }, + declaration, + config + ), potentialDuplicates: extractPotentialDuplicatesFromActions(sortedActions), flags: getEventFlags(event, config) }) @@ -304,6 +360,11 @@ export function applyDeclarationToEventIndex( updatedDeclaration, eventConfiguration ), + placeOfEvent: resolvePlaceOfEvent( + eventIndex, + updatedDeclaration, + eventConfiguration + ), declaration: updatedDeclaration } } diff --git a/packages/commons/src/events/test.utils.ts b/packages/commons/src/events/test.utils.ts index e92213cfcb8..f6856143402 100644 --- a/packages/commons/src/events/test.utils.ts +++ b/packages/commons/src/events/test.utils.ts @@ -187,6 +187,7 @@ function mapFieldTypeToMockValue( case FieldType.OFFICE: case FieldType.LINK_BUTTON: case FieldType.LOADER: + case FieldType.ALPHA_HIDDEN: return `${field.id}-${field.type}-${i}` case FieldType.VERIFICATION_STATUS: return 'verified' diff --git a/packages/events/src/router/event/__snapshots__/event.search.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.search.test.ts.snap index e744a0a5e05..87f7aad1872 100644 --- a/packages/events/src/router/event/__snapshots__/event.search.test.ts.snap +++ b/packages/events/src/router/event/__snapshots__/event.search.test.ts.snap @@ -36,6 +36,7 @@ exports[`Returns events based on the updatedAt column 1`] = ` "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -83,6 +84,7 @@ exports[`Returns events based on the updatedAt column 2`] = ` "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -134,6 +136,7 @@ exports[`Returns relevant events in right order 1`] = ` "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -181,6 +184,7 @@ exports[`Returns relevant events in right order 1`] = ` "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -226,6 +230,7 @@ exports[`Returns relevant events in right order 1`] = ` "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -288,6 +293,7 @@ exports[`Returns relevant events in right order 2`] = ` "registrationNumber": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "REGISTERED", "trackingId": "[sanitized]", @@ -723,6 +729,7 @@ exports[`User only sees tennis club membership events within their jurisdiction "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -768,6 +775,7 @@ exports[`User only sees tennis club membership events within their jurisdiction "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -818,6 +826,7 @@ exports[`User only sees tennis club membership events within their jurisdiction "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -863,6 +872,7 @@ exports[`User only sees tennis club membership events within their jurisdiction "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -908,6 +918,7 @@ exports[`User only sees tennis club membership events within their jurisdiction "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -957,6 +968,7 @@ exports[`User with my-jurisdiction scope can see events from other offices based "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", @@ -994,6 +1006,7 @@ exports[`User with my-jurisdiction scope only sees events created by system user ], "id": "[sanitized]", "legalStatuses": {}, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "NOTIFIED", "trackingId": "[sanitized]", @@ -1042,6 +1055,7 @@ exports[`User with my-jurisdiction scope only sees events from their primary off "createdByUserType": "[sanitized]", }, }, + "placeOfEvent": "[sanitized]", "potentialDuplicates": [], "status": "DECLARED", "trackingId": "[sanitized]", diff --git a/packages/events/src/service/indexing/indexing.test.ts b/packages/events/src/service/indexing/indexing.test.ts index 25d9e1e6bcc..e647f5e63c4 100644 --- a/packages/events/src/service/indexing/indexing.test.ts +++ b/packages/events/src/service/indexing/indexing.test.ts @@ -10,14 +10,30 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ +import { http, HttpResponse } from 'msw' import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' import { + ActionType, + AddressType, createPrng, + EventConfig, + EventDocument, + EventIndex, + EventState, + field, + FieldConditional, + FieldType, + generateActionDeclarationInput, + generateUuid, + Location, LocationType, QueryType, - TENNIS_CLUB_MEMBERSHIP + TENNIS_CLUB_MEMBERSHIP, + TestUserRole } from '@opencrvs/commons/events' +import { SCOPES } from '@opencrvs/commons' import { + createSystemTestClient, createTestClient, setupTestCase, TEST_USER_DEFAULT_SCOPES @@ -26,6 +42,8 @@ import { getEventIndexName, getOrCreateClient } from '@events/storage/elasticsearch' +import { mswServer } from '@events/tests/msw' +import { env } from '@events/environment' import { buildElasticQueryFromSearchPayload, withJurisdictionFilters @@ -115,6 +133,7 @@ test('records are indexed with full location hierarchy', async () => { status: 'DECLARED', createdAtLocation: [parentLocation.id, childLocation.id], updatedAtLocation: [parentLocation.id, childLocation.id], + placeOfEvent: [parentLocation.id, childLocation.id], legalStatuses: { DECLARED: { createdAtLocation: [parentLocation.id, childLocation.id] @@ -135,6 +154,7 @@ test('records are indexed with full location hierarchy', async () => { expect(results).toHaveLength(1) expect(results[0].createdAtLocation).toEqual(childLocation.id) expect(results[0].updatedAtLocation).toEqual(childLocation.id) + expect(results[0].placeOfEvent).toEqual(childLocation.id) expect(results[0].legalStatuses.DECLARED?.createdAtLocation).toEqual( childLocation.id ) @@ -159,6 +179,7 @@ test('records are indexed with full location hierarchy', async () => { expect(results2).toHaveLength(1) expect(results2[0].createdAtLocation).toEqual(childLocation.id) + expect(results2[0].placeOfEvent).toEqual(childLocation.id) expect(results2[0].updatedAtLocation).toEqual(childLocation.id) expect(results2[0].legalStatuses.DECLARED?.createdAtLocation).toEqual( childLocation.id @@ -832,3 +853,582 @@ test('builds Address field query', async () => { } }) }) + +describe('placeOfEvent location hierarchy handling', () => { + let client: ReturnType + let esClient: ReturnType + + let declarationWithHomeAddress: EventState + let generator: Awaited>['generator'] + let seed: Awaited>['seed'] + let grandParentLocation: Location + let parentLocation: Location + let childOffice: Location + let modifiedEventConfig: EventConfig + beforeEach(async () => { + // Setup: Generate location IDs upfront + const prng = createPrng(942) + const childOfficeId = generateUuid(prng) + const parentLocationId = generateUuid(prng) + + // Setup: Configure event with conditional address fields + const createAddressField = ( + id: string, + conditionals?: FieldConditional[] + ) => ({ + id, + type: FieldType.ADDRESS, + label: { + id: 'storybook.address.label', + defaultMessage: 'Address', + description: 'The title for the address input' + }, + defaultValue: { + country: 'FAR', + addressType: AddressType.DOMESTIC + }, + conditionals, + configuration: { + streetAddressForm: [ + { + id: 'town', + required: false, + label: { + id: 'field.address.town.label', + defaultMessage: 'Town', + description: 'This is the label for the field' + }, + type: FieldType.TEXT + } + ] + } + }) + + modifiedEventConfig = { + ...tennisClubMembershipEvent, + declaration: { + ...tennisClubMembershipEvent.declaration, + pages: tennisClubMembershipEvent.declaration.pages.map((page, i) => { + if (i !== 0) { + return page + } + return { + ...page, + fields: [ + ...page.fields.filter((x) => x.type !== FieldType.ADDRESS), + { + id: 'selected.address.type', + type: FieldType.TEXT, + label: { + id: 'addressType.label', + defaultMessage: 'Address Type', + description: '' + }, + defaultValue: 'home.address' + }, + createAddressField('home.address', [ + { + type: 'SHOW', + conditional: field('selected.address.type').isEqualTo( + 'home.address' + ) + } + ]), + createAddressField('office.address', [ + { + type: 'SHOW', + conditional: field('selected.address.type').isEqualTo( + 'office.address' + ) + } + ]), + { + id: 'locationId', + type: FieldType.ALPHA_HIDDEN, + label: { + defaultMessage: 'Health Institution', + description: 'This is the label for the field', + id: 'event.birth.action.declare.form.section.child.field.birthLocation.label' + }, + parent: [ + field('selected.address.type'), + field('home.address'), + field('office.address') + ], + value: [ + field('home.address').get('administrativeArea'), + field('office.address').get('administrativeArea') + ] + } + ] + } + }) + }, + placeOfEvent: field('locationId') + } + mswServer.use( + http.get(`${env.COUNTRY_CONFIG_URL}/events`, () => { + return HttpResponse.json([modifiedEventConfig]) + }) + ) + + // Setup: seed, user, client, esClient + const { generator: g, seed: s } = await setupTestCase( + 942, + modifiedEventConfig + ) + generator = g + seed = s + + const user = seed.user({ + role: TestUserRole.enum.LOCAL_REGISTRAR, + name: [{ use: 'en', family: 'Doe', given: ['John'] }], + primaryOfficeId: childOfficeId, + fullHonorificName: undefined + }) + + client = createTestClient(user, [ + ...TEST_USER_DEFAULT_SCOPES, + `search[event=${TENNIS_CLUB_MEMBERSHIP},access=all]`, + SCOPES.RECORD_REINDEX + ]) + esClient = getOrCreateClient() + + // Setup: Create location hierarchy (grandparent -> parent -> child office) + grandParentLocation = { + ...generator.locations.set(1, prng)[0], + locationType: LocationType.enum.ADMIN_STRUCTURE, + name: 'Grand Parent locations' + } + parentLocation = { + ...generator.locations.set(1, prng)[0], + id: parentLocationId, + parentId: grandParentLocation.id, + locationType: LocationType.enum.ADMIN_STRUCTURE, + name: 'Parent location' + } + childOffice = { + ...generator.locations.set(1, prng)[0], + id: user.primaryOfficeId, + parentId: parentLocationId, + name: 'Child location', + locationType: LocationType.enum.CRVS_OFFICE + } + + await seed.locations([grandParentLocation, parentLocation, childOffice]) + + declarationWithHomeAddress = { + ...generateActionDeclarationInput( + modifiedEventConfig, + ActionType.DECLARE, + createPrng(100) + ), + 'selected.address.type': 'home.address', + 'home.address': { + country: 'FAR', + streetLevelDetails: { town: 'Gazipur' }, + addressType: AddressType.DOMESTIC, + administrativeArea: parentLocationId + }, + locationId: parentLocationId + } + }) + + test('placeOfEvent resolves from conditional address fields and returns leaf-level locations in search results', async () => { + // Test Part 1: Declare event with home.address, verify ES contains full hierarchy + const createdEvent = await client.event.create( + generator.event.create({ type: TENNIS_CLUB_MEMBERSHIP }) + ) + + await client.event.actions.declare.request( + generator.event.actions.declare(createdEvent.id, { + declaration: declarationWithHomeAddress, + keepAssignment: true + }) + ) + + await client.event.actions.validate.request( + generator.event.actions.validate(createdEvent.id, { + declaration: declarationWithHomeAddress, + keepAssignment: true + }) + ) + + // Verify: Elasticsearch document contains full location hierarchies + const esSearchResponse = await esClient.search({ + index: getEventIndexName(TENNIS_CLUB_MEMBERSHIP), + body: { query: { match_all: {} } } + }) + + expect(esSearchResponse.hits.hits).toHaveLength(1) + expect(esSearchResponse.hits.hits[0]._source).toMatchObject({ + id: createdEvent.id, + type: TENNIS_CLUB_MEMBERSHIP, + status: 'DECLARED', + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + updatedAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + placeOfEvent: [grandParentLocation.id, parentLocation.id], + legalStatuses: { + DECLARED: { + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ] + } + }, + declaration: { + home____address: { + addressType: 'DOMESTIC', + country: 'FAR', + streetLevelDetails: { + town: 'Gazipur' + }, + administrativeArea: [grandParentLocation.id, parentLocation.id] + } + } + }) + + // Verify: Search API returns only leaf-level locations (no hierarchy) + const { results } = await client.event.search({ + query: { type: 'and', clauses: [{ eventType: TENNIS_CLUB_MEMBERSHIP }] } + }) + + expect(results).toHaveLength(1) + expect(results[0].createdAtLocation).toEqual(childOffice.id) + expect(results[0].updatedAtLocation).toEqual(childOffice.id) + expect(results[0].placeOfEvent).toEqual(parentLocation.id) + expect(results[0].legalStatuses.DECLARED?.createdAtLocation).toEqual( + childOffice.id + ) + + // Test Part 2: Register event with office.address, verify placeOfEvent updates correctly + const declarationWithoutHomeAddress = Object.keys( + declarationWithHomeAddress + ).reduce((obj, key) => { + if (key !== 'home.address') { + obj[key] = (declarationWithHomeAddress as EventState)[key] + } + return obj + }, {} as EventState) + + await client.event.actions.register.request( + generator.event.actions.register(createdEvent.id, { + declaration: { + ...declarationWithoutHomeAddress, + 'selected.address.type': 'office.address', + 'office.address': { + country: 'FAR', + streetLevelDetails: { town: 'Dhaka' }, + addressType: AddressType.DOMESTIC, + administrativeArea: grandParentLocation.id + }, + locationId: grandParentLocation.id + } + }) + ) + + // Verify: Search results still return leaf-level locations after update + const { results: updatedResults } = await client.event.search({ + query: { type: 'and', clauses: [{ eventType: TENNIS_CLUB_MEMBERSHIP }] } + }) + + expect(updatedResults).toHaveLength(1) + expect(updatedResults[0].createdAtLocation).toEqual(childOffice.id) + expect(updatedResults[0].updatedAtLocation).toEqual(childOffice.id) + expect(updatedResults[0].placeOfEvent).toEqual(grandParentLocation.id) + expect(updatedResults[0].legalStatuses.DECLARED?.createdAtLocation).toEqual( + childOffice.id + ) + }) + + test('placeOfEvent hierarchy is preserved after elasticsearch reindexing', async () => { + const NUMBER_OF_EVENTS = 2 + + // Step 1: Create and declare multiple events with home address + const createdEvents: EventDocument[] = [] + + for (let i = 0; i < NUMBER_OF_EVENTS; i++) { + const event = await client.event.create( + generator.event.create({ type: TENNIS_CLUB_MEMBERSHIP }) + ) + + await client.event.actions.declare.request( + generator.event.actions.declare(event.id, { + declaration: declarationWithHomeAddress, + keepAssignment: true + }) + ) + + await client.event.actions.validate.request( + generator.event.actions.validate(event.id, { + declaration: declarationWithHomeAddress, + keepAssignment: true + }) + ) + + createdEvents.push(event) + } + + // Step 2: Verify events are indexed correctly BEFORE reindexing + const initialSearchResponse = await esClient.search({ + index: getEventIndexName(TENNIS_CLUB_MEMBERSHIP), + body: { query: { match_all: {} } } + }) + + expect(initialSearchResponse.hits.hits).toHaveLength(NUMBER_OF_EVENTS) + + const expectedLocationHierarchy = { + type: TENNIS_CLUB_MEMBERSHIP, + status: 'DECLARED', + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + updatedAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + placeOfEvent: [grandParentLocation.id, parentLocation.id], + legalStatuses: { + DECLARED: { + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ] + } + }, + declaration: { + home____address: { + addressType: 'DOMESTIC', + country: 'FAR', + streetLevelDetails: { + town: 'Gazipur' + }, + administrativeArea: [grandParentLocation.id, parentLocation.id] + } + } + } + + for (const hit of initialSearchResponse.hits.hits) { + expect(hit._source).toMatchObject(expectedLocationHierarchy) + } + + // Step 3: Set up mock for reindex endpoint + const reindexSpy = vi.fn() + + mswServer.use( + http.post(`${env.COUNTRY_CONFIG_URL}/reindex`, async (req) => { + const body = await req.request.json() + reindexSpy(body) + return HttpResponse.json({}) + }) + ) + + // Step 4: Perform reindexing + const sysClient = createSystemTestClient('test-system', [ + SCOPES.RECORD_REINDEX + ]) + + await sysClient.event.reindex() + + // Refresh index to make reindexed documents searchable + await esClient.indices.refresh({ + index: getEventIndexName(TENNIS_CLUB_MEMBERSHIP) + }) + + // Step 5: Verify location hierarchy is preserved AFTER reindexing + const reindexedSearchResponse = await esClient.search({ + index: getEventIndexName(TENNIS_CLUB_MEMBERSHIP), + body: { query: { match_all: {} } } + }) + + expect(reindexedSearchResponse.hits.hits).toHaveLength(NUMBER_OF_EVENTS) + + for (const hit of reindexedSearchResponse.hits.hits) { + const source = hit._source as EventIndex + + // Verify this is one of our created events + const matchingEvent = createdEvents.find((e) => e.id === source.id) + expect(matchingEvent).toBeDefined() + + // Verify complete location hierarchy is intact + expect(source).toMatchObject({ + id: matchingEvent?.id, + ...expectedLocationHierarchy + }) + } + }) + + test('records are indexed with createdAtLocation for AddressType.INTERNATIONAL', async () => { + // Step 1: Create and declare events with international address + + const declarationWithInternationalAddress = { + ...generateActionDeclarationInput( + modifiedEventConfig, + ActionType.DECLARE, + createPrng(100) + ), + 'selected.address.type': 'home.address', + 'home.address': { + country: 'USA', + addressType: AddressType.INTERNATIONAL, + streetLevelDetails: { + town: 'Oklahoma' + } + }, + locationId: undefined + } + + const event = await client.event.create( + generator.event.create({ type: TENNIS_CLUB_MEMBERSHIP }) + ) + + await client.event.actions.declare.request( + generator.event.actions.declare(event.id, { + declaration: declarationWithInternationalAddress, + keepAssignment: true + }) + ) + + await client.event.actions.validate.request( + generator.event.actions.validate(event.id, { + declaration: declarationWithInternationalAddress, + keepAssignment: true + }) + ) + + // Step 2: Verify events are indexed correctly BEFORE reindexing + const initialSearchResponse = await esClient.search({ + index: getEventIndexName(TENNIS_CLUB_MEMBERSHIP), + body: { query: { match_all: {} } } + }) + + expect(initialSearchResponse.hits.hits).toHaveLength(1) + + const expectedLocationHierarchy = { + type: TENNIS_CLUB_MEMBERSHIP, + status: 'DECLARED', + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + updatedAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + placeOfEvent: [grandParentLocation.id, parentLocation.id, childOffice.id], + legalStatuses: { + DECLARED: { + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ] + } + }, + declaration: { + home____address: { + addressType: AddressType.INTERNATIONAL, + country: 'USA', + streetLevelDetails: { + town: 'Oklahoma' + } + } + } + } + + expect(initialSearchResponse.hits.hits[0]._source).toMatchObject( + expectedLocationHierarchy + ) + }) + + test('records are indexed with createdAtLocation for no placeOfEvent config', async () => { + // Step 1: Create and declare events with international address + mswServer.use( + http.get(`${env.COUNTRY_CONFIG_URL}/events`, () => { + return HttpResponse.json([ + { ...modifiedEventConfig, placeOfEvent: undefined } + ]) + }) + ) + + const event = await client.event.create( + generator.event.create({ type: TENNIS_CLUB_MEMBERSHIP }) + ) + + await client.event.actions.declare.request( + generator.event.actions.declare(event.id, { + declaration: declarationWithHomeAddress, + keepAssignment: true + }) + ) + + await client.event.actions.validate.request( + generator.event.actions.validate(event.id, { + declaration: declarationWithHomeAddress, + keepAssignment: true + }) + ) + + // Step 2: Verify events are indexed correctly BEFORE reindexing + const initialSearchResponse = await esClient.search({ + index: getEventIndexName(TENNIS_CLUB_MEMBERSHIP), + body: { query: { match_all: {} } } + }) + + expect(initialSearchResponse.hits.hits).toHaveLength(1) + + const expectedLocationHierarchy = { + type: TENNIS_CLUB_MEMBERSHIP, + status: 'DECLARED', + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + updatedAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ], + placeOfEvent: [grandParentLocation.id, parentLocation.id, childOffice.id], + legalStatuses: { + DECLARED: { + createdAtLocation: [ + grandParentLocation.id, + parentLocation.id, + childOffice.id + ] + } + }, + declaration: { + home____address: { + addressType: 'DOMESTIC', + country: 'FAR', + streetLevelDetails: { + town: 'Gazipur' + }, + administrativeArea: [grandParentLocation.id, parentLocation.id] + } + } + } + + expect(initialSearchResponse.hits.hits[0]._source).toMatchObject( + expectedLocationHierarchy + ) + }) +}) diff --git a/packages/events/src/service/indexing/indexing.ts b/packages/events/src/service/indexing/indexing.ts index eab344beecb..56500a159c0 100644 --- a/packages/events/src/service/indexing/indexing.ts +++ b/packages/events/src/service/indexing/indexing.ts @@ -101,6 +101,7 @@ function mapFieldTypeToElasticsearch( case FieldType.PAGE_HEADER: case FieldType.EMAIL: case FieldType.TIME: + case FieldType.ALPHA_HIDDEN: return { type: 'text' } case FieldType.EMAIL: return { @@ -346,12 +347,17 @@ export async function indexEventsInBulk( ) { const esClient = getOrCreateClient() + const locationHierarchyCache = new Map() const indexedDocs = await Promise.all( batch.map(async (doc) => { const config = getEventConfigById(configs, doc.type) const eventIndex = eventToEventIndex(doc, config) const eventIndexWithLocationHierarchy = - await getEventIndexWithLocationHierarchy(config, eventIndex) + await getEventIndexWithLocationHierarchy( + config, + eventIndex, + locationHierarchyCache + ) return [ { index: { _index: getEventIndexName(doc.type), _id: doc.id } }, eventIndexWithLocationHierarchy diff --git a/packages/events/src/service/indexing/utils.ts b/packages/events/src/service/indexing/utils.ts index f23ba9c1368..d26dcb42aa6 100644 --- a/packages/events/src/service/indexing/utils.ts +++ b/packages/events/src/service/indexing/utils.ts @@ -114,6 +114,21 @@ const LocationFieldTypes: FieldType[] = [ FieldType.OFFICE ] +/** + * Extracts only the leaf-level location from Elasticsearch location hierarchies. + * + * Elasticsearch stores locations as arrays of UUIDs representing the full administrative + * hierarchy from top to bottom (e.g., [province_uuid, district_uuid, facility_uuid]). + * This function extracts only the leaf-level location UUID (the last element in each array), + * which represents the most specific location. + * + * Example: { locationId: [province_uuid, distrcit_uuid, crvs_office_uuid] } + * → { locationId: crvs_office_uuid } // Only the leaf-level location + * + * @param eventConfig Event configuration containing field definitions + * @param event Event index retrieved from Elasticsearch + * @returns Event index with only leaf-level location UUIDs (last element from each hierarchy) + */ export function getEventIndexWithoutLocationHierarchy( eventConfig: EventConfig, event: EventIndex @@ -123,6 +138,7 @@ export function getEventIndexWithoutLocationHierarchy( // Normalize top-level locations event.createdAtLocation = takeLast(event.createdAtLocation) event.updatedAtLocation = takeLast(event.updatedAtLocation) + event.placeOfEvent = takeLast(event.placeOfEvent) if (event.legalStatuses.DECLARED) { event.legalStatuses.DECLARED.createdAtLocation = takeLast( @@ -163,10 +179,26 @@ export function getEventIndexWithoutLocationHierarchy( return event } -const locationHierarchyCache = new Map() +/** + * Expands leaf-level location UUIDs into full administrative hierarchies for Elasticsearch indexing. + * + * It takes an event with single location UUIDs (leaf-level) (ex: event.createdAtLocation) and expands each into an array representing the + * complete administrative hierarchy from top to bottom. + * + * Example: { locationId: location_uuid } + * → { locationId: [province_uuid, district_uuid, location_uuid] } + * + * Uses an in-memory cache to avoid redundant database lookups for the same location hierarchies. + * + * @param eventConfig Event configuration containing field definitions + * @param event Event index with leaf-level location UUIDs + * @param locationHierarchyCache Optional in-memory cache for location hierarchies, to avoid redundant lookups + * @returns Event index with full location hierarchies (arrays of UUIDs from top to leaf) + */ export async function getEventIndexWithLocationHierarchy( eventConfig: EventConfig, - event: EventIndex + event: EventIndex, + locationHierarchyCache?: Map ) { const buildFullLocationHierarchy = async ( locationId: UUID @@ -174,12 +206,14 @@ export async function getEventIndexWithLocationHierarchy( if (!locationId) { return [] } - if (locationHierarchyCache.has(locationId)) { + if (locationHierarchyCache && locationHierarchyCache.has(locationId)) { return locationHierarchyCache.get(locationId) || [locationId] } const hierarchyRows = await getLocationHierarchyRaw(locationId) - locationHierarchyCache.set(locationId, hierarchyRows) - return locationHierarchyCache.get(locationId) || [locationId] + if (locationHierarchyCache) { + locationHierarchyCache.set(locationId, hierarchyRows) + } + return hierarchyRows } /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const tempEvent = { ...event, declaration: { ...event.declaration } } as any @@ -189,6 +223,13 @@ export async function getEventIndexWithLocationHierarchy( event.createdAtLocation ) } + + if (event.placeOfEvent) { + tempEvent.placeOfEvent = await buildFullLocationHierarchy( + event.placeOfEvent + ) + } + if (event.updatedAtLocation) { tempEvent.updatedAtLocation = await buildFullLocationHierarchy( event.updatedAtLocation diff --git a/packages/events/src/storage/postgres/events/locations.ts b/packages/events/src/storage/postgres/events/locations.ts index bc817da9504..30714f5a5c9 100644 --- a/packages/events/src/storage/postgres/events/locations.ts +++ b/packages/events/src/storage/postgres/events/locations.ts @@ -118,7 +118,14 @@ export async function getLocations({ let query = db .selectFrom('locations') - .select(['id', 'name', 'parentId', 'validUntil', 'locationType']) + .select([ + 'id', + 'name', + 'parentId', + 'validUntil', + 'locationType', + 'administrativeAreaId' + ]) .where('deletedAt', 'is', null) .$narrowType<{ deletedAt: null diff --git a/packages/events/src/tests/utils.ts b/packages/events/src/tests/utils.ts index fb02aff70f4..723b984c6fd 100644 --- a/packages/events/src/tests/utils.ts +++ b/packages/events/src/tests/utils.ts @@ -49,6 +49,7 @@ export const UNSTABLE_EVENT_FIELDS = [ 'updatedBy', 'acceptedAt', 'dateOfEvent', + 'placeOfEvent', 'registrationNumber', 'originalActionId' ]