From 69af464ef55a92edc8b0132e1e8e8be6d0fad7e1 Mon Sep 17 00:00:00 2001 From: Pyry Rouvila Date: Wed, 13 Sep 2023 11:05:18 +0300 Subject: [PATCH] feat: allow searching with national id & pagination (#27) --- package-lock.json | 99 +++++++------------ packages/dci-api/package.json | 2 +- packages/dci-api/src/index.ts | 2 +- packages/dci-api/src/server.ts | 2 +- .../dci-api/src/sync-search/test-payload.json | 5 +- packages/dci-api/src/validations.ts | 40 +++++--- .../src/dci-to-opencrvs.ts | 7 +- .../src/opencrvs-to-dci.ts | 59 +++++++++-- packages/opencrvs-api/src/index.ts | 8 ++ packages/opencrvs-api/src/types.ts | 1 + 10 files changed, 136 insertions(+), 89 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e3565a..e3e1d7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2653,14 +2653,15 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.23.0", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.1.tgz", + "integrity": "sha512-iM/2Qp+y7zKrX1sf45sPvvE7CGly8AKSR8Ua7cXAszXCK/To5i/L8AwiheEaBSVcZ6R7Em7kTcyZWN5H2ivcEQ==", "dev": true, - "license": "MIT", "dependencies": { - "@open-draft/deferred-promise": "^2.1.0", + "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", - "headers-polyfill": "^3.1.0", + "is-node-process": "^1.2.0", "outvariant": "^1.2.1", "strict-event-emitter": "^0.5.0" }, @@ -2701,14 +2702,16 @@ } }, "node_modules/@open-draft/deferred-promise": { - "version": "2.1.0", - "dev": true, - "license": "MIT" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true }, "node_modules/@open-draft/logger": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", "dev": true, - "license": "MIT", "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" @@ -2716,8 +2719,9 @@ }, "node_modules/@open-draft/until": { "version": "2.1.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true }, "node_modules/@parcel/watcher": { "version": "2.3.0", @@ -5775,9 +5779,10 @@ } }, "node_modules/headers-polyfill": { - "version": "3.1.2", - "dev": true, - "license": "MIT" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.2.3.tgz", + "integrity": "sha512-oj6MO8sdFQ9gQQedSVdMGh96suxTNp91vPQu7C4qx/57FqYsA5TiNr92nhIZwVQq8zygn4nu3xS1aEqpakGqdw==", + "dev": true }, "node_modules/help-me": { "version": "4.2.0", @@ -6249,8 +6254,9 @@ }, "node_modules/is-node-process": { "version": "1.2.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true }, "node_modules/is-number": { "version": "7.0.0", @@ -6857,16 +6863,17 @@ "license": "MIT" }, "node_modules/msw": { - "version": "0.0.0-fetch.rc-16", + "version": "0.0.0-fetch.rc-19", + "resolved": "https://registry.npmjs.org/msw/-/msw-0.0.0-fetch.rc-19.tgz", + "integrity": "sha512-AMFmUDGNsg/OJdaCTjiO5RUghdmfRJx2X8LU6apginM5Qqk4lyWmQvrtpF3e0KG/1DByFgxvEwoGarXRX88EZg==", "dev": true, "hasInstallScript": true, - "license": "MIT", "dependencies": { "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/js-levenshtein": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@mswjs/cookies": "^1.0.0", - "@mswjs/interceptors": "^0.23.0", + "@mswjs/interceptors": "^0.25.1", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.4.1", "@types/js-levenshtein": "^1.1.1", @@ -6875,7 +6882,7 @@ "chokidar": "^3.4.2", "formdata-node": "4.4.1", "graphql": "^15.0.0 || ^16.7.0", - "headers-polyfill": "^3.1.2", + "headers-polyfill": "^3.2.3", "inquirer": "^8.2.0", "is-node-process": "^1.2.0", "js-levenshtein": "^1.1.6", @@ -6897,7 +6904,7 @@ "url": "https://opencollective.com/mswjs" }, "peerDependencies": { - "typescript": ">= 4.4.x <= 5.1.x" + "typescript": ">= 4.4.x <= 5.2.x" }, "peerDependenciesMeta": { "typescript": { @@ -7285,8 +7292,9 @@ }, "node_modules/outvariant": { "version": "1.4.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "dev": true }, "node_modules/p-limit": { "version": "3.1.0", @@ -8247,8 +8255,9 @@ }, "node_modules/strict-event-emitter": { "version": "0.5.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.0.tgz", + "integrity": "sha512-sqnMpVJLSB3daNO6FcvsEk4Mq5IJeAwDeH80DP1S8+pgxrF6yZnE1+VeapesGled7nEcIkz1Ax87HzaIy+02kA==", + "dev": true }, "node_modules/string_decoder": { "version": "1.3.0", @@ -9049,14 +9058,14 @@ "@hapi/hapi": "^21.3.2", "hapi-pino": "^12.1.0", "lodash": "^4.17.21", - "typescript": "^5.2.2", + "typescript": "^5.1.6", "zod": "^3.22.2", "zod-validation-error": "^1.5.0" }, "devDependencies": { "@types/lodash": "^4.14.197", "@types/node": "^20.5.3", - "msw": "0.0.0-fetch.rc-16", + "msw": "0.0.0-fetch.rc-19", "nodemon": "^3.0.1", "openapi-typescript": "^6.5.3", "pino-pretty": "^10.2.0", @@ -9105,7 +9114,7 @@ "dependencies": { "graphql": "^16.8.0", "graphql-tag": "^2.12.6", - "typescript": "^5.2.2" + "typescript": "^5.1.6" }, "devDependencies": { "@graphql-codegen/add": "^5.0.0", @@ -9115,42 +9124,6 @@ "@graphql-codegen/typescript-operations": "^4.0.0" } }, - "packages/opencrvs-api/node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "packages/opencrvs-api/node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "packages/opencrvs-api/node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "packages/opencrvs-api/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", diff --git a/packages/dci-api/package.json b/packages/dci-api/package.json index 89f0175..2a5516c 100644 --- a/packages/dci-api/package.json +++ b/packages/dci-api/package.json @@ -22,7 +22,7 @@ "devDependencies": { "@types/lodash": "^4.14.197", "@types/node": "^20.5.3", - "msw": "0.0.0-fetch.rc-16", + "msw": "0.0.0-fetch.rc-19", "nodemon": "^3.0.1", "openapi-typescript": "^6.5.3", "pino-pretty": "^10.2.0", diff --git a/packages/dci-api/src/index.ts b/packages/dci-api/src/index.ts index 4bb2f6c..77f3d45 100644 --- a/packages/dci-api/src/index.ts +++ b/packages/dci-api/src/index.ts @@ -5,4 +5,4 @@ createServer().then(async (server) => { }) export type * from './registry-core-api' -export type { SyncSearchRequest } from './validations' +export type { SyncSearchRequest, EventType } from './validations' diff --git a/packages/dci-api/src/server.ts b/packages/dci-api/src/server.ts index 6c18e8d..83155d0 100644 --- a/packages/dci-api/src/server.ts +++ b/packages/dci-api/src/server.ts @@ -2,7 +2,7 @@ import * as Hapi from '@hapi/hapi' import { HOST, PORT, DEFAULT_TIMEOUT_MS, NODE_ENV } from './constants' import { routes } from './routes' import { ParseError } from 'dci-opencrvs-bridge' -import { AuthorizationError } from 'opencrvs-api/src/error' +import { AuthorizationError } from 'opencrvs-api' import pino from 'hapi-pino' import { ValidationError, error } from './error' diff --git a/packages/dci-api/src/sync-search/test-payload.json b/packages/dci-api/src/sync-search/test-payload.json index 5ee7b94..528d8b1 100644 --- a/packages/dci-api/src/sync-search/test-payload.json +++ b/packages/dci-api/src/sync-search/test-payload.json @@ -19,8 +19,11 @@ "reference_id": "123456789020211216223812", "timestamp": "2022-12-04T17:20:07-04:00", "registry_type": "civil", - "event_type": "1", "search_criteria": { + "reg_event_type": { + "namespace": "?", + "value": "1" + }, "query_type": "idtype-value", "query": { "identifier_type": { diff --git a/packages/dci-api/src/validations.ts b/packages/dci-api/src/validations.ts index e86f97a..547ead0 100644 --- a/packages/dci-api/src/validations.ts +++ b/packages/dci-api/src/validations.ts @@ -1,10 +1,11 @@ -import { type TypeOf, z } from 'zod' +import { type TypeOf, z, type ZodType } from 'zod' +import { Event } from 'opencrvs-api' const dateTime = z.string().datetime({ offset: true }) const paginationRequest = z.object({ - page_size: z.number(), - page_number: z.number().optional() + page_size: z.number().positive().int(), + page_number: z.number().positive().int().optional() }) const searchSort = z.object({ @@ -52,16 +53,32 @@ const header = z.object({ is_msg_encrypted: z.boolean().optional() }) -const reference = z.object({ - namespace: z.string().optional(), - refUri: z.string().optional(), - value: z.string() +/** + * https://digital-convergence-initiative-d.gitbook.io/dci-standards-1/standards/1.-crvs/6.5-data-standards/6.5.2-code-directory#cd.04-vital_events + * OpenCRVS only supports [1 = Live Birth] [2 = Death] [4 = Marriage] + */ +const eventTypes = z.enum(['1', '2', '4']).transform((number) => { + switch (number) { + case '1': + return Event.Birth + case '2': + return Event.Death + case '4': + return Event.Marriage + } }) +const reference = (value: ZodType = z.string()) => + z.object({ + namespace: z.string().optional(), + refUri: z.string().optional(), + value + }) + const attributeValue = z.string().or(z.number()).or(z.boolean()) const identifierTypeValue = z.object({ - identifier_type: reference, + identifier_type: reference(), identifier_value: attributeValue }) @@ -101,9 +118,9 @@ const searchRequest = z.object({ search_criteria: z .object({ version, - reg_type: reference.optional(), - reg_event_type: reference.optional(), - result_record_type: reference, + reg_type: reference().optional(), + reg_event_type: reference(eventTypes).optional(), + result_record_type: reference(), sort: z.array(searchSort).optional(), pagination: paginationRequest.optional(), consent: consent.optional(), @@ -123,3 +140,4 @@ export const requestSchema = z.object({ }) export type SyncSearchRequest = TypeOf +export type EventType = TypeOf diff --git a/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts index 287762e..6833a32 100644 --- a/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts +++ b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts @@ -1,4 +1,4 @@ -import type { SearchEventsQueryVariables } from 'opencrvs-api' +import { type SearchEventsQueryVariables } from 'opencrvs-api' import type { SyncSearchRequest } from 'dci-api' import { ParseError } from './error' @@ -19,7 +19,6 @@ export function searchRequestToAdvancedSearchParameters( // let sortOrder: "asc" | "desc" = "asc"; // let sortColumn: string | undefined; - // TODO: Support more than one identifier if (query.identifier_type.value === 'BRN') { parameters.registrationNumber = query.identifier_value } else if (query.identifier_type.value === 'DRN') { @@ -28,10 +27,14 @@ export function searchRequestToAdvancedSearchParameters( parameters.registrationNumber = query.identifier_value } else if (query.identifier_type.value === 'OPENCRVS_RECORD_ID') { parameters.recordId = query.identifier_value + } else if (query.identifier_type.value === 'NID') { + parameters.nationalId = query.identifier_value } else { throw new ParseError('Unsupported identifier type') } + parameters.event = request.search_criteria.reg_event_type?.value + if ((sort?.length ?? 0) > 1) { throw new ParseError('Sorting by more than one attribute is not supported') } diff --git a/packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts b/packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts index 8d61846..10a5747 100644 --- a/packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts +++ b/packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts @@ -2,12 +2,13 @@ import type { Registration, BirthRegistration, DeathRegistration, - MarriageRegistration + MarriageRegistration, + IdentityType } from 'opencrvs-api' import type { operations, components, SyncSearchRequest } from 'dci-api' import type { SearchResponseWithMetadata } from './types' import { ParseError } from './error' -import { isNil } from 'lodash/fp' +import { compact, isNil } from 'lodash/fp' const name = ({ firstNames, @@ -34,6 +35,21 @@ const sex = (value: string) => { } } +const identifier = ({ id: value, type }: IdentityType) => { + switch (type) { + case 'DEATH_REGISTRATION_NUMBER': + return { type: 'DRN', value } + case 'BIRTH_REGISTRATION_NUMBER': + return { type: 'BRN', value } + case 'MARRIAGE_REGISTRATION_NUMBER': + return { type: 'MRN', value } + case 'NATIONAL_ID': + return { type: 'NID', value } + default: + throw new ParseError('Unimplemented identifier type') + } +} + function birthPersonRecord(registration: BirthRegistration) { /* eslint-disable @typescript-eslint/no-non-null-assertion */ const motherIdentifier = !isNil(registration.mother?.identifier?.[0]?.id) @@ -81,6 +97,11 @@ function deathPersonRecord(registration: DeathRegistration) { /* eslint-enable @typescript-eslint/no-non-null-assertion */ return { + identifier: compact( + registration.deceased.identifier?.map((identity) => + identity !== null ? identifier(identity) : null + ) + ) as any, // TODO: Change this to a proper identity type birthdate: registration.deceased?.birthDate ?? undefined, deathdate: registration.deceased?.deceased?.deathDate ?? undefined, ...name({ @@ -132,12 +153,23 @@ export function searchResponseBuilder( registrations: Registration[], { referenceId, - timestamp + timestamp, + pageSize, + pageNumber = 1 }: { referenceId: string timestamp: components['schemas']['DateTime'] + pageSize?: number + pageNumber?: number } ): components['schemas']['SearchResponse']['search_response'][number] { + pageSize ??= registrations.length // Return all records in one page if page size isn't defined + + const paginatedRegistrations = registrations.slice( + (pageNumber - 1) * pageSize, + pageNumber * pageSize + ) + return { reference_id: referenceId, timestamp, @@ -151,13 +183,18 @@ export function searchResponseBuilder( namespace: 'ns:dci:vital-events:v1', value: eventType('Birth') // TODO: Shouldn't this be per reg_record? }, - reg_records: registrations.map((registration) => + reg_records: paginatedRegistrations.map((registration) => isBirthEventSearchSet(registration) ? birthPersonRecord(registration) : isMarriageEventSearchSet(registration) ? marriagePersonRecord(registration) : deathPersonRecord(registration) ) + }, + pagination: { + page_number: pageNumber, + page_size: pageSize, + total_count: registrations.length } } } @@ -187,11 +224,15 @@ export function registrySyncSearchBuilder( correlation_id: '<>', // TODO: Couldn't find this from Gitbook search_response: responses.flatMap( ({ registrations, originalRequest, responseFinishedTimestamp }) => - // TODO: Improve the GraphQL types to assert the results do exist, but they can be an empty array - searchResponseBuilder(registrations, { - referenceId: originalRequest.reference_id, - timestamp: responseFinishedTimestamp.toISOString() - }) + registrations.length > 0 + ? searchResponseBuilder(registrations, { + referenceId: originalRequest.reference_id, + timestamp: responseFinishedTimestamp.toISOString(), + pageSize: originalRequest.search_criteria.pagination?.page_size, + pageNumber: + originalRequest.search_criteria.pagination?.page_number + }) + : [] ) } } satisfies operations['post_reg_sync_search']['responses']['default']['content']['application/json'] diff --git a/packages/opencrvs-api/src/index.ts b/packages/opencrvs-api/src/index.ts index 0526c28..e03f46a 100644 --- a/packages/opencrvs-api/src/index.ts +++ b/packages/opencrvs-api/src/index.ts @@ -111,11 +111,13 @@ export const FETCH_REGISTRATION = gql` __typename child { id + birthDate name { use firstNames familyName } + gender } } ... on DeathRegistration { @@ -127,6 +129,11 @@ export const FETCH_REGISTRATION = gql` firstNames familyName } + gender + identifier { + id + type + } } eventLocation { __typename @@ -190,4 +197,5 @@ export async function fetchRegistration( } export * from './types' +export * from './error' export { OPENCRVS_GATEWAY_URL } from './constants' diff --git a/packages/opencrvs-api/src/types.ts b/packages/opencrvs-api/src/types.ts index 1d33993..dda7ebf 100644 --- a/packages/opencrvs-api/src/types.ts +++ b/packages/opencrvs-api/src/types.ts @@ -53,3 +53,4 @@ export type { IdentityType, SearchEventsQueryVariables } from './gateway' +export { Event } from './gateway'