diff --git a/packages/client/src/v2-events/features/events/AdvancedSearch/AdvancedSearch.tsx b/packages/client/src/v2-events/features/events/AdvancedSearch/AdvancedSearch.tsx index 211a82cd45..2cf82090b8 100644 --- a/packages/client/src/v2-events/features/events/AdvancedSearch/AdvancedSearch.tsx +++ b/packages/client/src/v2-events/features/events/AdvancedSearch/AdvancedSearch.tsx @@ -10,6 +10,7 @@ */ import React, { useState } from 'react' import { defineMessages, useIntl } from 'react-intl' +import { useLocation } from 'react-router-dom' import { Content, ContentSize, @@ -38,6 +39,8 @@ const messages = defineMessages(messagesToDefine) function AdvancedSearch() { const intl = useIntl() const allEvents = useEventConfigurations() + const location = useLocation() + const searchParams = location.state const advancedSearchEvents = allEvents.filter( (event) => event.advancedSearch.length > 0 @@ -76,7 +79,7 @@ function AdvancedSearch() { titleColor={'copy'} > {currentTabSections.length > 0 && ( - + )} diff --git a/packages/client/src/v2-events/features/events/AdvancedSearch/SearchResult.tsx b/packages/client/src/v2-events/features/events/AdvancedSearch/SearchResult.tsx new file mode 100644 index 0000000000..f61cbc6bb6 --- /dev/null +++ b/packages/client/src/v2-events/features/events/AdvancedSearch/SearchResult.tsx @@ -0,0 +1,515 @@ +/* + * 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, { useState } from 'react' +import { parse } from 'query-string' +import { useTypedParams } from 'react-router-typesafe-routes/dom' +import { defineMessages, useIntl } from 'react-intl' +import styled, { useTheme } from 'styled-components' +import { Link, useNavigate } from 'react-router-dom' +import { mapKeys } from 'lodash' +import { useQuery } from '@tanstack/react-query' +import { ErrorText, Link as StyledLink } from '@opencrvs/components/lib' +import { + EventSearchIndex, + FieldConfig, + FieldValue, + getAllFields, + validateFieldInput, + workqueues, + defaultColumns, + getOrThrow +} from '@opencrvs/commons/client' +import { useWindowSize } from '@opencrvs/components/src/hooks' +import { + Workqueue, + ColumnContentAlignment +} from '@opencrvs/components/lib/Workqueue' +import { useEventConfiguration } from '@client/v2-events/features/events/useEventConfiguration' +import { ROUTES } from '@client/v2-events/routes' +import { getAllUniqueFields } from '@client/v2-events/utils' +import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents' +import { WQContentWrapper } from '@client/v2-events/features/workqueues/components/ContentWrapper' +import { LoadingIndicator } from '@client/v2-events/components/LoadingIndicator' +import { IconWithName } from '@client/v2-events/components/IconWithName' +import { setEmptyValuesForFields } from '@client/v2-events/components/forms/utils' +import { formattedDuration } from '@client/utils/date-formatting' +import { useIntlFormatMessageWithFlattenedParams } from '@client/v2-events/features/workqueues/utils' +import { WorkqueueLayout } from '@client/v2-events/layouts/workqueues' +import { useTRPC } from '@client/v2-events/trpc' + +const SORT_ORDER = { + ASCENDING: 'asc', + DESCENDING: 'desc' +} as const + +const COLUMNS = { + ICON_WITH_NAME: 'iconWithName', + ICON_WITH_NAME_EVENT: 'iconWithNameEvent', + EVENT: 'event', + DATE_OF_EVENT: 'dateOfEvent', + SENT_FOR_REVIEW: 'sentForReview', + SENT_FOR_UPDATES: 'sentForUpdates', + SENT_FOR_APPROVAL: 'sentForApproval', + SENT_FOR_VALIDATION: 'sentForValidation', + REGISTERED: 'registered', + LAST_UPDATED: 'lastUpdated', + ACTIONS: 'actions', + NOTIFICATION_SENT: 'notificationSent', + NAME: 'name', + TRACKING_ID: 'trackingId', + REGISTRATION_NO: 'registrationNumber', + NONE: 'none' +} as const + +const NondecoratedLink = styled(Link)` + text-decoration: none; + color: 'primary'; +` + +const SearchParamContainer = styled.div` + margin: 16px 0px; + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + color: ${({ theme }) => theme.colors.primaryDark}; + @media (max-width: ${({ theme }) => theme.grid.breakpoints.lg}px) { + max-height: 200px; + overflow-y: scroll; + } +` + +interface Column { + label?: string + width: number + key: string + sortFunction?: (columnName: string) => void + isActionColumn?: boolean + isSorted?: boolean + alignment?: ColumnContentAlignment +} + +function validateEventSearchParams( + fields: FieldConfig[], + values: Record +) { + const errors = fields.reduce( + ( + errorResults: { message: string; id: string; value: FieldValue }[], + field: FieldConfig + ) => { + const fieldErrors = validateFieldInput({ + field: { ...field, required: false }, + value: values[field.id] + }) + + if (fieldErrors.length === 0) { + return errorResults + } + + // For backend, use the default message without translations. + const errormessageWithId = fieldErrors.map((error) => ({ + message: error.message.defaultMessage, + id: field.id, + value: values[field.id] + })) + + return [...errorResults, ...errormessageWithId] + }, + [] + ) + + return errors +} + +function changeSortedColumn( + columnName: string, + presentSortedCol: (typeof COLUMNS)[keyof typeof COLUMNS], + presentSortOrder: (typeof SORT_ORDER)[keyof typeof SORT_ORDER] +) { + let newSortedCol: (typeof COLUMNS)[keyof typeof COLUMNS] + let newSortOrder: (typeof SORT_ORDER)[keyof typeof SORT_ORDER] = + SORT_ORDER.ASCENDING + + switch (columnName) { + case COLUMNS.ICON_WITH_NAME: + newSortedCol = COLUMNS.NAME + break + case COLUMNS.NAME: + newSortedCol = COLUMNS.NAME + break + case COLUMNS.EVENT: + newSortedCol = COLUMNS.EVENT + break + case COLUMNS.DATE_OF_EVENT: + newSortedCol = COLUMNS.DATE_OF_EVENT + break + case COLUMNS.SENT_FOR_REVIEW: + newSortedCol = COLUMNS.SENT_FOR_REVIEW + break + case COLUMNS.SENT_FOR_UPDATES: + newSortedCol = COLUMNS.SENT_FOR_UPDATES + break + case COLUMNS.SENT_FOR_APPROVAL: + newSortedCol = COLUMNS.SENT_FOR_APPROVAL + break + case COLUMNS.REGISTERED: + newSortedCol = COLUMNS.REGISTERED + break + case COLUMNS.SENT_FOR_VALIDATION: + newSortedCol = COLUMNS.SENT_FOR_VALIDATION + break + case COLUMNS.NOTIFICATION_SENT: + newSortedCol = COLUMNS.NOTIFICATION_SENT + break + case COLUMNS.LAST_UPDATED: + newSortedCol = COLUMNS.LAST_UPDATED + break + case COLUMNS.TRACKING_ID: + newSortedCol = COLUMNS.TRACKING_ID + break + case COLUMNS.REGISTRATION_NO: + newSortedCol = COLUMNS.REGISTRATION_NO + break + default: + newSortedCol = COLUMNS.NONE + } + + if (newSortedCol === presentSortedCol) { + if (presentSortOrder === SORT_ORDER.ASCENDING) { + newSortOrder = SORT_ORDER.DESCENDING + } else { + newSortOrder = SORT_ORDER.ASCENDING + newSortedCol = COLUMNS.NONE + } + } + + return { + newSortedCol: newSortedCol, + newSortOrder: newSortOrder + } +} + +const messagesToDefine = { + edit: { + defaultMessage: 'Edit', + description: 'Edit button text', + id: 'buttons.edit' + }, + noResult: { + id: 'v2.search.noResult', + defaultMessage: 'No results', + description: 'The no result text' + }, + searchResult: { + defaultMessage: 'Search results', + description: + 'The label for search result header in advancedSearchResult page', + id: 'v2.advancedSearchResult.table.searchResult' + }, + name: { + defaultMessage: 'Name', + description: 'Name label', + id: 'v2.onstants.name' + }, + event: { + defaultMessage: 'Event', + description: 'Label for Event of event in work queue list item', + id: 'v2.constants.event' + }, + eventDate: { + defaultMessage: 'Date of event', + description: 'Label for event date in list item', + id: 'v2.constants.eventDate' + }, + noResults: { + defaultMessage: 'No result', + description: + 'Text to display if the search return no results for the current filters', + id: 'v2.constants.noResults' + }, + queryError: { + defaultMessage: 'An error occurred while searching', + description: 'The error message shown when a search query fails', + id: 'v2.error.search' + } +} + +const messages = defineMessages(messagesToDefine) + +export const SearchResult = () => { + const intl = useIntl() + const trpc = useTRPC() + const { getOutbox, getDrafts } = useEvents() + const flattenedIntl = useIntlFormatMessageWithFlattenedParams() + const { width: windowWidth } = useWindowSize() + const theme = useTheme() + const { eventType } = useTypedParams(ROUTES.V2.SEARCH_RESULT) + const { eventConfiguration } = useEventConfiguration(eventType) + + const [sortedCol, setSortedCol] = useState< + (typeof COLUMNS)[keyof typeof COLUMNS] + >(COLUMNS.NONE) + const [sortOrder, setSortOrder] = useState< + (typeof SORT_ORDER)[keyof typeof SORT_ORDER] + >(SORT_ORDER.ASCENDING) + + const searchParams = parse(window.location.search) + const normalizedSearchParams: Record = Object.fromEntries( + Object.entries(searchParams).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(',') : value ?? '' + ]) + ) + + const allFields = getAllUniqueFields(eventConfiguration) + const outbox = getOutbox() + const drafts = getDrafts() + + const fieldValueErrors = validateEventSearchParams(allFields, searchParams) + + const { + data: queryData, + isLoading, + error: queryError + } = useQuery({ + ...trpc.event.search.queryOptions({ + ...searchParams, + type: eventType + }), + queryKey: trpc.event.search.queryKey({ + ...searchParams, + type: eventType + }) + }) + + const total = queryData?.length || 0 + const workqueueId = 'all' + const workqueueConfig = + workqueueId in workqueues + ? workqueues[workqueueId as keyof typeof workqueues] + : null + + if (!workqueueConfig) { + return null + } + + const onColumnClick = (columnName: string) => { + const { newSortedCol, newSortOrder } = changeSortedColumn( + columnName, + sortedCol, + sortOrder + ) + setSortedCol(newSortedCol) + setSortOrder(newSortOrder) + } + + const transformData = (eventData: EventSearchIndex[]) => { + return eventData + .map((event) => { + const { data, ...rest } = event + return { ...rest, ...mapKeys(data, (_, key) => `${key}`) } + }) + .map((doc) => { + const isInOutbox = outbox.some( + (outboxEvent) => outboxEvent.id === doc.id + ) + const isInDrafts = drafts.some((draft) => draft.id === doc.id) + + const getEventStatus = () => { + if (isInOutbox) { + return 'OUTBOX' + } + if (isInDrafts) { + return 'DRAFT' + } + return doc.status + } + + const eventWorkqueue = getOrThrow( + eventConfiguration.workqueues.find( + (wq) => wq.id === workqueueConfig.id + ), + `Could not find workqueue config for ${workqueueConfig.id}` + ) + + const allPropertiesWithEmptyValues = setEmptyValuesForFields( + getAllFields(eventConfiguration) + ) + + const fieldsWithPopulatedValues: Record = + eventWorkqueue.fields.reduce( + (acc, field) => ({ + ...acc, + [field.column]: flattenedIntl.formatMessage(field.label, { + ...allPropertiesWithEmptyValues, + ...doc + }) + }), + {} + ) + const titleColumnId = workqueueConfig.columns[0].id + const status = doc.status + + return { + ...fieldsWithPopulatedValues, + ...doc, + event: intl.formatMessage(eventConfiguration.label), + createdAt: formattedDuration(new Date(doc.createdAt)), + modifiedAt: formattedDuration(new Date(doc.modifiedAt)), + status: intl.formatMessage( + { + id: `events.status`, + defaultMessage: + '{status, select, OUTBOX {Syncing..} CREATED {Draft} VALIDATED {Validated} DRAFT {Draft} DECLARED {Declared} REGISTERED {Registered} other {Unknown}}' + }, + { + status: getEventStatus() + } + ), + [titleColumnId]: ( + + + + ) + } + }) + } + + function getDefaultColumns(): Array { + return ( + (workqueueConfig && + workqueueConfig.defaultColumns.map( + (column): Column => ({ + label: + column in defaultColumns + ? intl.formatMessage( + defaultColumns[column as keyof typeof defaultColumns].label + ) + : '', + width: 25, + key: column, + sortFunction: onColumnClick, + isSorted: sortedCol === column + }) + )) ?? + [] + ) + } + + // @todo: update when workqueue actions buttons are updated + // @TODO: separate types for action button vs other columns + function getColumns(): Array { + if (windowWidth > theme.grid.breakpoints.lg) { + return ( + (workqueueConfig && + workqueueConfig.columns.map((column) => ({ + label: intl.formatMessage(column.label), + width: 35, + key: column.id, + sortFunction: onColumnClick, + isSorted: sortedCol === column.id + }))) ?? + [] + ) + } else { + return ( + (workqueueConfig && + workqueueConfig.columns + .map((column) => ({ + label: intl.formatMessage(column.label), + width: 35, + key: column.id, + sortFunction: onColumnClick, + isSorted: sortedCol === column.id + })) + .slice(0, 2)) ?? + [] + ) + } + } + + let content + let noResultText = intl.formatMessage(messages.noResult) + if (isLoading) { + content = ( +
+ +
+ ) + } else if (queryError || fieldValueErrors.length > 0) { + noResultText = '' + content = ( + + {intl.formatMessage(messages.queryError)} + + ) + } else if (queryData && total > 0) { + content = ( + + ) + } + return ( +
+ + + } + title={`${intl.formatMessage(messages.searchResult)} ${ + isLoading ? '' : ' (' + total + ')' + }`} + > + {content} + + +
+ ) +} + +function SearchModifierComponent({ + searchParams +}: { + searchParams: Record +}) { + const navigate = useNavigate() + const intl = useIntl() + + return ( + <> + + + navigate(ROUTES.V2.ADVANCED_SEARCH.path, { state: searchParams }) + } + > + {intl.formatMessage(messages.edit)} + + + + ) +} diff --git a/packages/client/src/v2-events/features/events/AdvancedSearch/TabSearch.tsx b/packages/client/src/v2-events/features/events/AdvancedSearch/TabSearch.tsx index da353942e6..ac8ce76313 100644 --- a/packages/client/src/v2-events/features/events/AdvancedSearch/TabSearch.tsx +++ b/packages/client/src/v2-events/features/events/AdvancedSearch/TabSearch.tsx @@ -11,6 +11,7 @@ import * as React from 'react' import styled from 'styled-components' import { useIntl, defineMessages, IntlShape } from 'react-intl' +import { useNavigate } from 'react-router-dom' import { Accordion } from '@opencrvs/components' import { FieldValue } from '@opencrvs/commons/client' import { Icon } from '@opencrvs/components/lib/Icon' @@ -18,6 +19,8 @@ import { Button } from '@opencrvs/components/lib/Button' import { EventConfig } from '@opencrvs/commons' import { FormFieldGenerator } from '@client/v2-events/components/forms/FormFieldGenerator' import { getAllUniqueFields } from '@client/v2-events/utils' +import { ROUTES } from '@client/v2-events/routes' +import { getValidationErrorsForForm } from '@client/v2-events/components/forms/validation' const SearchButton = styled(Button)` margin-top: 32px; @@ -26,7 +29,7 @@ const SearchButton = styled(Button)` const messagesToDefine = { search: { defaultMessage: 'Search', - description: 'The title of search input submit button', + description: 'Label for search button', id: 'v2.buttons.search' }, hide: { @@ -47,7 +50,8 @@ function getSectionFields( event: EventConfig, formValues: Record, handleFieldChange: (fieldId: string, value: FieldValue) => void, - intl: IntlShape + intl: IntlShape, + fieldValues?: Record ) { const advancedSearchSections = event.advancedSearch const allUniqueFields = getAllUniqueFields(event) @@ -55,11 +59,11 @@ function getSectionFields( const advancedSearchFieldId = section.fields.map( (f: { fieldId: string }) => f.fieldId ) - const fields = allUniqueFields.filter((field) => + const advancedSearchFields = allUniqueFields.filter((field) => advancedSearchFieldId.includes(field.id) ) - const modifiedFields = fields.map((f) => ({ + const modifiedFields = advancedSearchFields.map((f) => ({ ...f, required: false // advanced search fields need not be required })) @@ -77,6 +81,7 @@ function getSectionFields( fields={modifiedFields} formData={formValues} id={section.id} + initialValues={fieldValues} setAllFieldsDirty={false} onChange={(updatedValues) => { Object.entries(updatedValues).forEach(([fieldId, value]) => { @@ -89,16 +94,28 @@ function getSectionFields( }) } -export function TabSearch({ currentEvent }: { currentEvent: EventConfig }) { +export function TabSearch({ + currentEvent, + fieldValues +}: { + currentEvent: EventConfig + fieldValues?: Record +}) { + const [hasEnoughParams, setHasEnoughParams] = React.useState(false) const intl = useIntl() const [formValues, setFormValues] = React.useState< Record >({}) + const navigate = useNavigate() React.useEffect(() => { setFormValues({}) }, [currentEvent]) + React.useEffect(() => { + setHasEnoughParams(Object.entries(formValues).length > 0) + }, [formValues]) + const handleFieldChange = (fieldId: string, value: FieldValue) => { setFormValues((prev) => ({ ...prev, @@ -106,25 +123,70 @@ export function TabSearch({ currentEvent }: { currentEvent: EventConfig }) { })) } + const advancedSearchSections = currentEvent.advancedSearch + const allUniqueFields = getAllUniqueFields(currentEvent) + const fieldErrors = advancedSearchSections.reduce( + (errorsOnFields, currentSection) => { + const advancedSearchFieldIds = currentSection.fields.map( + (f: { fieldId: string }) => f.fieldId + ) + const advancedSearchFields = allUniqueFields.filter((field) => + advancedSearchFieldIds.includes(field.id) + ) + + const modifiedFields = advancedSearchFields.map((f) => ({ + ...f, + required: false // advanced search fields need not be required + })) + + const err = getValidationErrorsForForm(modifiedFields, formValues) + + return { + ...errorsOnFields, + ...err + } + }, + {} + ) + + const allErrors = Object.values(fieldErrors).flatMap( + // @ts-ignore + (errObj) => errObj.errors + ) + + const handleSearch = () => { + const searchParams = new URLSearchParams() + + Object.entries(formValues).forEach(([key, value]) => { + if (value) { + searchParams.append(key, String(value)) + } // Convert all values to strings + }) + + const navigateTo = ROUTES.V2.SEARCH_RESULT.buildPath({ + eventType: currentEvent.id + }) + + navigate(`${navigateTo}?${searchParams.toString()}`) + } + const SectionFields = getSectionFields( currentEvent, formValues, handleFieldChange, - intl + intl, + fieldValues ) - const hasEnoughParams = Object.entries(formValues).length > 0 const Search = ( 0} id="search" size="large" type="primary" - onClick={() => { - alert(JSON.stringify(formValues)) // @todo replace with actual search - }} + onClick={handleSearch} > {intl.formatMessage(messages.search)} diff --git a/packages/client/src/v2-events/routes/config.tsx b/packages/client/src/v2-events/routes/config.tsx index 1bc3640207..f01fdcefbc 100644 --- a/packages/client/src/v2-events/routes/config.tsx +++ b/packages/client/src/v2-events/routes/config.tsx @@ -26,6 +26,7 @@ import { router as workqueueRouter } from '@client/v2-events/features/workqueues import { EventOverviewLayout } from '@client/v2-events/layouts' import { TRPCErrorBoundary } from '@client/v2-events/routes/TRPCErrorBoundary' import { TRPCProvider } from '@client/v2-events/trpc' +import { SearchResult } from '@client/v2-events/features/events/AdvancedSearch/SearchResult' import { ROUTES } from './routes' /** @@ -124,6 +125,10 @@ export const routesConfig = { { path: ROUTES.V2.ADVANCED_SEARCH.path, element: + }, + { + path: ROUTES.V2.SEARCH_RESULT.path, + element: } ] } satisfies RouteObject diff --git a/packages/client/src/v2-events/routes/routes.ts b/packages/client/src/v2-events/routes/routes.ts index 3f4282c1d5..74a7944903 100644 --- a/packages/client/src/v2-events/routes/routes.ts +++ b/packages/client/src/v2-events/routes/routes.ts @@ -85,7 +85,10 @@ export const ROUTES = { } ), WORKQUEUES: workqueueRoutes, - ADVANCED_SEARCH: route('advanced-search') + ADVANCED_SEARCH: route('advanced-search'), + SEARCH_RESULT: route('search-result/:eventType', { + params: { eventType: string().defined() } + }) } ) } diff --git a/packages/commons/src/conditionals/validate.ts b/packages/commons/src/conditionals/validate.ts index cd262bacf4..88b0de061b 100644 --- a/packages/commons/src/conditionals/validate.ts +++ b/packages/commons/src/conditionals/validate.ts @@ -230,7 +230,7 @@ function runCustomFieldValidations({ * e.g. email is proper format, date is a valid date, etc. * for custom validations @see runCustomFieldValidations */ -function validateFieldInput({ +export function validateFieldInput({ field, value }: { diff --git a/packages/commons/src/events/AdvancedSearchConfig.ts b/packages/commons/src/events/AdvancedSearchConfig.ts index 37979e9b78..a37b3e0093 100644 --- a/packages/commons/src/events/AdvancedSearchConfig.ts +++ b/packages/commons/src/events/AdvancedSearchConfig.ts @@ -12,7 +12,6 @@ import { z } from 'zod' import { TranslationConfig } from './TranslationConfig' export const AdvancedSearchConfig = z.object({ - id: z.string().describe('Advanced search section id'), title: TranslationConfig.describe('Advanced search tab title'), fields: z .array( diff --git a/packages/commons/src/events/EventIndex.ts b/packages/commons/src/events/EventIndex.ts index fef6d0db34..a478fbbc85 100644 --- a/packages/commons/src/events/EventIndex.ts +++ b/packages/commons/src/events/EventIndex.ts @@ -16,4 +16,11 @@ export const EventIndex = EventMetadata.extend({ data: z.record(z.string(), z.any()) }) +export const EventSearchIndex = z.record(z.string(), z.any()).and( + z.object({ + type: z.string() // Ensures "type" (event-id) exists and is a string + }) +) + +export type EventSearchIndex = z.infer export type EventIndex = z.infer diff --git a/packages/commons/src/fixtures/tennis-club-membership-event.ts b/packages/commons/src/fixtures/tennis-club-membership-event.ts index dab986be1a..6fdd472f96 100644 --- a/packages/commons/src/fixtures/tennis-club-membership-event.ts +++ b/packages/commons/src/fixtures/tennis-club-membership-event.ts @@ -1243,7 +1243,6 @@ export const tennisClubMembershipEvent = defineConfig({ ], advancedSearch: [ { - id: 'RANDOM', title: { defaultMessage: 'Tennis club registration search', description: 'This is what this event is referred as in the system', diff --git a/packages/events/src/router/event/index.ts b/packages/events/src/router/event/index.ts index 08977c6b91..9cabddc325 100644 --- a/packages/events/src/router/event/index.ts +++ b/packages/events/src/router/event/index.ts @@ -20,7 +20,7 @@ import { getEventById } from '@events/service/events/events' import { presignFilesInEvent } from '@events/service/files' -import { getIndexedEvents } from '@events/service/indexing/indexing' +import { getIndex, getIndexedEvents } from '@events/service/indexing/indexing' import { EventConfig, getUUID, @@ -39,7 +39,8 @@ import { NotifyActionInput, RegisterActionInput, ValidateActionInput, - FieldValue + FieldValue, + EventSearchIndex } from '@opencrvs/commons/events' import { router, publicProcedure } from '@events/router/trpc' import { approveCorrection } from '@events/service/events/actions/approve-correction' @@ -273,5 +274,10 @@ export const eventRouter = router({ logger.info(input.data) return getEventById(input.eventId) }) - }) + }), + search: publicProcedure + .input(EventSearchIndex) + .query(async ({ input, ctx }) => { + return getIndex(input) + }) }) diff --git a/packages/events/src/service/indexing/indexing.ts b/packages/events/src/service/indexing/indexing.ts index d8c6f8991f..cf6b891fcf 100644 --- a/packages/events/src/service/indexing/indexing.ts +++ b/packages/events/src/service/indexing/indexing.ts @@ -14,6 +14,7 @@ import { EventConfig, EventDocument, EventIndex, + EventSearchIndex, FieldConfig, FieldType, getCurrentEventState @@ -28,6 +29,8 @@ import { import { getAllFields, logger } from '@opencrvs/commons' import { Transform } from 'stream' import { z } from 'zod' +import { DEFAULT_SIZE, generateQuery } from './utils' + function eventToEventIndex(event: EventDocument): EventIndex { return encodeEventIndex(getCurrentEventState(event)) } @@ -302,3 +305,30 @@ export async function getIndexedEvents() { return events } + +export async function getIndex(eventParams: EventSearchIndex) { + const esClient = getOrCreateClient() + const { type, ...queryParams } = eventParams + + if (Object.values(queryParams).length === 0) { + throw new Error('No search params provided') + } + + const query = generateQuery(queryParams) + + const response = await esClient.search({ + index: getEventIndexName(type), + size: DEFAULT_SIZE, + request_cache: false, + query + }) + + const events = z.array(EventIndex).parse( + response.hits.hits + .map((hit) => hit._source) + .filter((event): event is EncodedEventIndex => event !== undefined) + .map((event) => decodeEventIndex(event)) + ) + + return events +} diff --git a/packages/events/src/service/indexing/utils.ts b/packages/events/src/service/indexing/utils.ts new file mode 100644 index 0000000000..d1828ee027 --- /dev/null +++ b/packages/events/src/service/indexing/utils.ts @@ -0,0 +1,34 @@ +/* + * 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 { EventSearchIndex } from '@opencrvs/commons/events' +import { + QueryDslQueryContainer, + SearchRequest +} from '@elastic/elasticsearch/lib/api/types' + +const FIELD_SEPARATOR = '____' +export const DEFAULT_SIZE = 10 + +export function generateQuery(event: Omit) { + const must: QueryDslQueryContainer[] = Object.entries(event).map( + ([key, value]) => ({ + match: { + [`data.${key.replaceAll('.', FIELD_SEPARATOR)}`]: value + } + }) + ) + + return { + bool: { + must + } + } as SearchRequest['query'] +}