= {
+ 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'
]