diff --git a/package-lock.json b/package-lock.json index eb09b0d..ba46c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1157,7 +1157,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.15.tgz", "integrity": "sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -4389,6 +4388,21 @@ "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==", "dev": true }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dateformat": { "version": "4.6.3", "dev": true, @@ -7866,8 +7880,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", @@ -9152,7 +9165,7 @@ "hapi-pino": "^12.1.0", "lodash": "^4.17.21", "ts-node": "^10.9.1", - "typescript": "^5.1.6", + "typescript": "^5.2.2", "zod": "^3.22.2", "zod-validation-error": "^1.5.0" }, @@ -9180,8 +9193,13 @@ "license": "ISC", "dependencies": { "@types/lodash": "^4.14.197", + "date-fns": "^2.30.0", "lodash": "^4.17.21", "typescript": "^5.2.2" + }, + "devDependencies": { + "@types/lodash": "^4.14.197", + "ts-node": "^10.9.1" } }, "packages/dci-opencrvs-bridge/node_modules/typescript": { @@ -9204,7 +9222,7 @@ "graphql": "^16.8.0", "graphql-tag": "^2.12.6", "jsonwebtoken": "^9.0.2", - "typescript": "^5.1.6" + "typescript": "^5.2.2" }, "devDependencies": { "@graphql-codegen/add": "^5.0.0", diff --git a/package.json b/package.json index c1bdabd..02dace1 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "scripts": { "start": "npm start --workspace=dci-api", "dev": "npm run dev --workspace=dci-api", - "test": "npm run test --workspaces", - "test:watch": "npm run test:watch --workspaces" + "test": "npm run test --workspace=dci-api --workspace=dci-opencrvs-bridge", + "test:watch": "npm run test:watch --workspace=dci-api --workspace=dci-opencrvs-bridge" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.4.1", diff --git a/packages/dci-api/package.json b/packages/dci-api/package.json index 58f5d21..a825555 100644 --- a/packages/dci-api/package.json +++ b/packages/dci-api/package.json @@ -20,7 +20,7 @@ "hapi-pino": "^12.1.0", "lodash": "^4.17.21", "ts-node": "^10.9.1", - "typescript": "^5.1.6", + "typescript": "^5.2.2", "zod": "^3.22.2", "zod-validation-error": "^1.5.0" }, diff --git a/packages/dci-api/src/index.ts b/packages/dci-api/src/index.ts index 77f3d45..83907ee 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, EventType } from './validations' +export type * from './validations' diff --git a/packages/dci-api/src/validations.ts b/packages/dci-api/src/validations.ts index 52959b4..c2107d0 100644 --- a/packages/dci-api/src/validations.ts +++ b/packages/dci-api/src/validations.ts @@ -39,10 +39,10 @@ const authorize = z.object({ const languageCode = z.string().regex(/^[a-z]{3,3}$/) -const version = z.string().optional().default('1.0.0') +const version = z.string().default('1.0.0') const syncHeader = z.object({ - version, + version: version.optional(), message_id: z.string(), message_ts: dateTime, action: z.literal('search'), @@ -54,7 +54,7 @@ const syncHeader = z.object({ }) const asyncHeader = z.object({ - version, + version: version.optional(), message_id: z.string(), message_ts: dateTime, action: z.literal('search'), @@ -87,39 +87,58 @@ const reference = (value: ZodType = z.string()) => value }) -const attributeValue = z.string().or(z.number()).or(z.boolean()) +const commonSearchCriteria = z.object({ + version: version.optional(), + reg_type: reference().optional(), + reg_event_type: reference(eventTypes), + result_record_type: reference(), + sort: z.array(searchSort).optional(), + pagination: paginationRequest.optional(), + consent: consent.optional(), + authorize: authorize.optional() +}) + +const identifier = z.enum(['BRN', 'DRN', 'MRN', 'OPENCRVS_RECORD_ID', 'NID']) const identifierTypeValue = z.object({ - identifier_type: reference(), - identifier_value: attributeValue + identifier_type: reference(identifier), + identifier_value: z.string() }) -const identifierTypeQuery = z.object({ - query_type: z.literal('idtype-value'), - query: identifierTypeValue -}) +const identifierTypeQuery = commonSearchCriteria.and( + z.object({ + query_type: z.literal('idtype-value'), + query: identifierTypeValue + }) +) + +const expressionCondition = z.enum(['and']) -const expressionCondition = z.enum(['and', 'or', 'not']) +const expression = z.enum(['gt', 'lt', 'eq', 'ge', 'le']) -const expressionOperator = z.enum(['gt', 'lt', 'eq', 'ge', 'le', 'in']) +const expressionSupportedFields = z.enum(['birthdate']) const expressionPredicate = z.object({ - attribute_name: z.string(), - operator: expressionOperator, - attribute_value: attributeValue + attribute_name: expressionSupportedFields, + operator: expression, + attribute_value: z.coerce.date() }) -const predicateQuery = z.object({ - query_type: z.literal('predicate'), - query: z.array( - z.object({ - seq_num: z.number().optional(), - expression1: expressionPredicate, - condition: expressionCondition.optional(), - expression2: expressionPredicate.optional() - }) - ) -}) +const predicateQuery = commonSearchCriteria.and( + z.object({ + query_type: z.literal('predicate'), + query: z.array( + z.object({ + seq_num: z.number().optional(), + expression1: expressionPredicate, + condition: expressionCondition, + expression2: expressionPredicate + }) + ) + }) +) + +const searchCriteria = predicateQuery.or(identifierTypeQuery) const searchRequest = z.object({ transaction_id: z.string().max(99), @@ -127,18 +146,7 @@ const searchRequest = z.object({ z.object({ reference_id: z.string(), timestamp: dateTime, - search_criteria: z - .object({ - version, - reg_type: reference().optional(), - reg_event_type: reference(eventTypes), - result_record_type: reference(), - sort: z.array(searchSort).optional(), - pagination: paginationRequest.optional(), - consent: consent.optional(), - authorize: authorize.optional() - }) - .and(predicateQuery.or(identifierTypeQuery)), + search_criteria: searchCriteria, locale: languageCode.optional().default('eng') }) ) @@ -159,3 +167,6 @@ export const asyncSearchRequestSchema = z.object({ export type SyncSearchRequest = TypeOf export type EventType = TypeOf export type AsyncSearchRequest = TypeOf +export type SearchCriteria = TypeOf +export type PredicateQuery = TypeOf +export type IdentifierTypeQuery = TypeOf diff --git a/packages/dci-opencrvs-bridge/package.json b/packages/dci-opencrvs-bridge/package.json index 6aae5ca..96f3861 100644 --- a/packages/dci-opencrvs-bridge/package.json +++ b/packages/dci-opencrvs-bridge/package.json @@ -4,9 +4,18 @@ "description": "Supplies tools and types for converting DCI search queries into OpenCRVS search queries, and converting the search results into DCI search results.", "main": "src/index.ts", "license": "ISC", + "scripts": { + "test": "TZ=utc node --require ts-node/register --test src/**/*.test.ts", + "test:watch": "TZ=utc node --require ts-node/register --test --watch src/**/*.test.ts" + }, "dependencies": { "@types/lodash": "^4.14.197", + "date-fns": "^2.30.0", "lodash": "^4.17.21", "typescript": "^5.2.2" + }, + "devDependencies": { + "@types/lodash": "^4.14.197", + "ts-node": "^10.9.1" } } diff --git a/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts deleted file mode 100644 index cae40b6..0000000 --- a/packages/dci-opencrvs-bridge/src/dci-to-opencrvs.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { type SearchEventsQueryVariables } from 'opencrvs-api' -import type { SyncSearchRequest } from 'dci-api' -import { ParseError } from './error' - -export function searchRequestToAdvancedSearchParameters( - request: SyncSearchRequest['message']['search_request'][number] -): SearchEventsQueryVariables { - const query = request.search_criteria.query as { - identifier_type: { value: string } - identifier_value: string - } - const sort = request.search_criteria.sort as - | Array<{ - attribute_name: 'dateOfDeclaration' - sort_order: 'asc' | 'desc' - }> - | undefined - const parameters: SearchEventsQueryVariables['advancedSearchParameters'] = {} - const sortBy = sort?.map(({ attribute_name: column, sort_order: order }) => ({ - column, - order - })) - - if (query.identifier_type.value === 'BRN') { - parameters.registrationNumber = query.identifier_value - } else if (query.identifier_type.value === 'DRN') { - parameters.registrationNumber = query.identifier_value - } else if (query.identifier_type.value === 'MRN') { - 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 - - return { advancedSearchParameters: parameters, sortBy } -} diff --git a/packages/dci-opencrvs-bridge/src/dci-to-opencrvs/dci-to-opencrvs.test.ts b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs/dci-to-opencrvs.test.ts new file mode 100644 index 0000000..c3bb3a9 --- /dev/null +++ b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs/dci-to-opencrvs.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from 'node:test' +import { searchRequestToAdvancedSearchParameters } from './dci-to-opencrvs' +import assert from 'node:assert' + +describe('DCI standard to OpenCRVS', () => { + // `gt` => date + 1 day + // `ge` => date + // `lt` => date - 1 day + // `le` => date + it('converts gt and lt properly', () => { + const parameters = searchRequestToAdvancedSearchParameters({ + reference_id: '123456789020211216223812', + timestamp: '2022-12-04T17:20:07-04:00', + search_criteria: { + reg_event_type: { namespace: '?', value: 'birth' }, + result_record_type: { value: 'person' }, + sort: [{ attribute_name: 'dateOfDeclaration', sort_order: 'asc' }], + pagination: { page_size: 5, page_number: 1 }, + query_type: 'predicate', + query: [ + { + expression1: { + attribute_name: 'birthdate', + operator: 'gt', + attribute_value: new Date('2010-05-04T00:00:00.000Z') + }, + condition: 'and', + expression2: { + attribute_name: 'birthdate', + operator: 'lt', + attribute_value: new Date('2022-05-04T00:00:00.000Z') + } + } + ] + }, + locale: 'eng' + }) + + assert.strictEqual( + parameters.advancedSearchParameters.childDoBStart, + '2010-05-05' + ) + assert.strictEqual( + parameters.advancedSearchParameters.childDoBEnd, + '2022-05-03' + ) + }) + + it('converts ge and le properly', () => { + const parameters = searchRequestToAdvancedSearchParameters({ + reference_id: '123456789020211216223812', + timestamp: '2022-12-04T17:20:07-04:00', + search_criteria: { + reg_event_type: { namespace: '?', value: 'birth' }, + result_record_type: { value: 'person' }, + sort: [{ attribute_name: 'dateOfDeclaration', sort_order: 'asc' }], + pagination: { page_size: 5, page_number: 1 }, + query_type: 'predicate', + query: [ + { + expression1: { + attribute_name: 'birthdate', + operator: 'ge', + attribute_value: new Date('2010-05-04') + }, + condition: 'and', + expression2: { + attribute_name: 'birthdate', + operator: 'le', + attribute_value: new Date('2022-05-04') + } + } + ] + }, + locale: 'eng' + }) + + assert.strictEqual( + parameters.advancedSearchParameters.childDoBStart, + '2010-05-04' + ) + assert.strictEqual( + parameters.advancedSearchParameters.childDoBEnd, + '2022-05-04' + ) + }) +}) diff --git a/packages/dci-opencrvs-bridge/src/dci-to-opencrvs/dci-to-opencrvs.ts b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs/dci-to-opencrvs.ts new file mode 100644 index 0000000..393e1b1 --- /dev/null +++ b/packages/dci-opencrvs-bridge/src/dci-to-opencrvs/dci-to-opencrvs.ts @@ -0,0 +1,115 @@ +import { type SearchEventsQueryVariables, Event } from 'opencrvs-api' +import { + type SyncSearchRequest, + type SearchCriteria, + type IdentifierTypeQuery +} from 'dci-api' +import { subDays, formatISOWithOptions, addDays } from 'date-fns/fp' + +const formatDate = formatISOWithOptions({ representation: 'date' }) + +function isIdentifierTypeQuery( + criteria: SearchCriteria +): criteria is IdentifierTypeQuery { + return criteria.query_type === 'idtype-value' +} + +function parameters(criteria: SearchCriteria) { + const parameters: SearchEventsQueryVariables['advancedSearchParameters'] = {} + + parameters.event = criteria.reg_event_type?.value + + if (isIdentifierTypeQuery(criteria)) { + if (criteria.query.identifier_type.value === 'BRN') { + parameters.registrationNumber = criteria.query.identifier_value + } else if (criteria.query.identifier_type.value === 'DRN') { + parameters.registrationNumber = criteria.query.identifier_value + } else if (criteria.query.identifier_type.value === 'MRN') { + parameters.registrationNumber = criteria.query.identifier_value + } else if (criteria.query.identifier_type.value === 'OPENCRVS_RECORD_ID') { + parameters.recordId = criteria.query.identifier_value + } else if (criteria.query.identifier_type.value === 'NID') { + parameters.nationalId = criteria.query.identifier_value + } + } else { + for (const criterion of criteria.query) { + if (criteria.reg_event_type.value === Event.Birth) { + if (criterion.expression1.operator === 'ge') { + parameters.childDoBStart = formatDate( + criterion.expression1.attribute_value + ) + } + + if (criterion.expression1.operator === 'gt') { + parameters.childDoBStart = formatDate( + addDays(1)(criterion.expression1.attribute_value) + ) + } + + if (criterion.expression2.operator === 'le') { + parameters.childDoBEnd = formatDate( + criterion.expression2.attribute_value + ) + } + + if (criterion.expression2.operator === 'lt') { + parameters.childDoBEnd = formatDate( + subDays(1)(criterion.expression2.attribute_value) + ) + } + + if (criterion.expression1.operator === 'eq') { + parameters.childDoB = formatDate( + criterion.expression1.attribute_value + ) + } + } else if (criteria.reg_event_type.value === Event.Death) { + if (criterion.expression1.operator === 'ge') { + parameters.deceasedDoBStart = formatDate( + criterion.expression1.attribute_value + ) + } + + if (criterion.expression1.operator === 'gt') { + parameters.deceasedDoBStart = formatDate( + addDays(1)(criterion.expression1.attribute_value) + ) + } + + if (criterion.expression2?.operator === 'le') { + parameters.deceasedDoBEnd = formatDate( + criterion.expression2.attribute_value + ) + } + + if (criterion.expression2?.operator === 'lt') { + parameters.deceasedDoBEnd = formatDate( + subDays(1)(criterion.expression2.attribute_value) + ) + } + + if (criterion.expression1.operator === 'eq') { + parameters.deceasedDoB = formatDate( + criterion.expression1.attribute_value + ) + } + } + } + } + + return parameters +} + +export function searchRequestToAdvancedSearchParameters( + request: SyncSearchRequest['message']['search_request'][number] +): SearchEventsQueryVariables { + const criteria = request.search_criteria + const sort = request.search_criteria.sort + const sortBy = sort?.map( + ({ attribute_name: column = '', sort_order: order }) => ({ + column, + order + }) + ) + return { advancedSearchParameters: parameters(criteria), sortBy } +} diff --git a/packages/dci-opencrvs-bridge/src/index.ts b/packages/dci-opencrvs-bridge/src/index.ts index 8074ae5..20ce206 100644 --- a/packages/dci-opencrvs-bridge/src/index.ts +++ b/packages/dci-opencrvs-bridge/src/index.ts @@ -1,3 +1,3 @@ -export * from './dci-to-opencrvs' -export * from './opencrvs-to-dci' +export * from './dci-to-opencrvs/dci-to-opencrvs' +export * from './opencrvs-to-dci/opencrvs-to-dci' export * from './error' diff --git a/packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts b/packages/dci-opencrvs-bridge/src/opencrvs-to-dci/opencrvs-to-dci.ts similarity index 98% rename from packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts rename to packages/dci-opencrvs-bridge/src/opencrvs-to-dci/opencrvs-to-dci.ts index 3697c0f..b4bdc3d 100644 --- a/packages/dci-opencrvs-bridge/src/opencrvs-to-dci.ts +++ b/packages/dci-opencrvs-bridge/src/opencrvs-to-dci/opencrvs-to-dci.ts @@ -7,8 +7,8 @@ import { Event } from 'opencrvs-api' import type { operations, components, SyncSearchRequest } from 'dci-api' -import type { SearchResponseWithMetadata } from './types' -import { ParseError } from './error' +import type { SearchResponseWithMetadata } from '../types' +import { ParseError } from '../error' import { compact, isNil } from 'lodash/fp' import { randomUUID } from 'node:crypto' diff --git a/packages/opencrvs-api/package.json b/packages/opencrvs-api/package.json index 84c79ef..fc3e7e8 100644 --- a/packages/opencrvs-api/package.json +++ b/packages/opencrvs-api/package.json @@ -12,7 +12,7 @@ "graphql": "^16.8.0", "graphql-tag": "^2.12.6", "jsonwebtoken": "^9.0.2", - "typescript": "^5.1.6" + "typescript": "^5.2.2" }, "devDependencies": { "@graphql-codegen/add": "^5.0.0",