diff --git a/.eslintrc.events.js b/.eslintrc.events.js index cc4b58754fc..9c543349624 100644 --- a/.eslintrc.events.js +++ b/.eslintrc.events.js @@ -21,8 +21,6 @@ module.exports = { '@typescript-eslint/no-unnecessary-boolean-literal-compare': 2, '@typescript-eslint/no-non-null-assertion': 2, '@typescript-eslint/no-unnecessary-condition': 1, - '@typescript-eslint/no-unsafe-argument': 1, - '@typescript-eslint/no-unsafe-return': 1, '@typescript-eslint/prefer-includes': 1, '@typescript-eslint/promise-function-async': 2, '@typescript-eslint/require-await': 2, diff --git a/packages/client/package.json b/packages/client/package.json index fef5a82eda0..98007dcd0cc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest", "lint": "yarn lint:css && yarn lint:ts", "lint:css": "stylelint 'src/**/*.{ts,tsx}'", - "lint:ts": "eslint --fix './src/**/*.{ts,tsx}' --max-warnings=314", + "lint:ts": "eslint --fix './src/**/*.{ts,tsx}' --max-warnings=293", "test:compilation": "tsc --noEmit", "extract:translations": "bash extract-translations.sh", "generate-gateway-types": "NODE_OPTIONS=--dns-result-order=ipv4first graphql-codegen --config codegen.ts && prettier --write src/utils/gateway.ts", diff --git a/packages/client/src/v2-events/STYLEGUIDE.md b/packages/client/src/v2-events/STYLEGUIDE.md index c623bedc921..267c058cb52 100644 --- a/packages/client/src/v2-events/STYLEGUIDE.md +++ b/packages/client/src/v2-events/STYLEGUIDE.md @@ -50,14 +50,51 @@ interface IApplicationConfig { } ``` -# Coding conventions, definition of done +## Naming functions -- When introducing a new `MessageDescriptor` create a new row in `client.csv` -- They should all have `v2.`-prefix +- Use find\* when you might return undefined + +good: + +``` +async function findEventConfigurationById({ + token, + eventType +}: { + token: string + eventType: string +}) { + const configurations = await getEventConfigurations(token) + + return configurations.find((config) => config.id === eventType) +} +``` + +- Use get\* when you can guarantee the result + +good: + +``` + +async function getEventConfigurationById({ +token, +eventType +}: { +token: string +eventType: string +}) { +const configurations = await getEventConfigurations(token) + +const configuration = configurations.find((config) => config.id === eventType) + +return getOrThrow(configuration), `No configuration found for event type: ${eventType}`) +} + +``` ## Naming, abbreviation -When naming things with known abbreviations use camel case format despite of it. +When naming things with known abbreviations use camelCase format good: @@ -93,3 +130,8 @@ export function printPDF(template: PDFTemplate, declarationId: string) { } } ``` + +# Coding conventions, definition of done + +- When introducing a new `MessageDescriptor` create a new row in `client.csv` +- Each message used under events should have `v2.`-prefix diff --git a/packages/client/src/v2-events/components/forms/utils.ts b/packages/client/src/v2-events/components/forms/utils.ts index 25c54ebc260..97df52815ed 100644 --- a/packages/client/src/v2-events/components/forms/utils.ts +++ b/packages/client/src/v2-events/components/forms/utils.ts @@ -21,8 +21,7 @@ import { validate, DateFieldValue, TextFieldValue, - RadioGroupFieldValue, - FileFieldValue + RadioGroupFieldValue } from '@opencrvs/commons/client' import { CheckboxFieldValue, diff --git a/packages/client/src/v2-events/features/events/actions/register/Review.tsx b/packages/client/src/v2-events/features/events/actions/register/Review.tsx index 8c34234f200..61904e693b0 100644 --- a/packages/client/src/v2-events/features/events/actions/register/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/register/Review.tsx @@ -9,7 +9,7 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import React, { useEffect, useRef } from 'react' +import React, { useEffect } from 'react' import { defineMessages } from 'react-intl' import { useNavigate } from 'react-router-dom' import { v4 as uuid } from 'uuid' diff --git a/packages/client/src/v2-events/features/events/registered-fields/LocationSearch.tsx b/packages/client/src/v2-events/features/events/registered-fields/LocationSearch.tsx index 210f11dc725..c70816fd68d 100644 --- a/packages/client/src/v2-events/features/events/registered-fields/LocationSearch.tsx +++ b/packages/client/src/v2-events/features/events/registered-fields/LocationSearch.tsx @@ -62,7 +62,6 @@ export function LocationSearch({ buttonLabel="Health facility" locationList={options} searchHandler={(location: SearchLocation) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return setFieldValue(props.id, location.id) } selectedLocation={initialLocation} diff --git a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts index 4a169ca7694..26ae3e8bfd0 100644 --- a/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts +++ b/packages/client/src/v2-events/features/events/useEvents/procedures/action.ts @@ -53,9 +53,9 @@ type Mutation = | typeof api.event.actions.register | typeof api.event.actions.validate | typeof api.event.actions.printCertificate - | typeof api.event.actions.correct.request - | typeof api.event.actions.correct.approve - | typeof api.event.actions.correct.reject + | typeof api.event.actions.correction.request + | typeof api.event.actions.correction.approve + | typeof api.event.actions.correction.reject type Procedure = | typeof utils.event.actions.declare @@ -63,9 +63,9 @@ type Procedure = | typeof utils.event.actions.register | typeof utils.event.actions.validate | typeof utils.event.actions.printCertificate - | typeof utils.event.actions.correct.request - | typeof utils.event.actions.correct.approve - | typeof utils.event.actions.correct.reject + | typeof utils.event.actions.correction.request + | typeof utils.event.actions.correction.approve + | typeof utils.event.actions.correction.reject /* * This makes sure that if you are offline and do @@ -183,7 +183,7 @@ utils.event.actions.printCertificate.setMutationDefaults( }) ) -utils.event.actions.correct.request.setMutationDefaults( +utils.event.actions.correction.request.setMutationDefaults( ({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, @@ -192,7 +192,7 @@ utils.event.actions.correct.request.setMutationDefaults( }) ) -utils.event.actions.correct.approve.setMutationDefaults( +utils.event.actions.correction.approve.setMutationDefaults( ({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, @@ -201,7 +201,7 @@ utils.event.actions.correct.approve.setMutationDefaults( }) ) -utils.event.actions.correct.reject.setMutationDefaults( +utils.event.actions.correction.reject.setMutationDefaults( ({ canonicalMutationFn }) => ({ retry: true, retryDelay: 10000, diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts index d0327ead632..dd4bfa7ef3f 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts @@ -125,16 +125,16 @@ export function useEvents() { ), correct: { request: useEventAction( - utils.event.actions.correct.request, - api.event.actions.correct.request + utils.event.actions.correction.request, + api.event.actions.correction.request ), approve: useEventAction( - utils.event.actions.correct.approve, - api.event.actions.correct.approve + utils.event.actions.correction.approve, + api.event.actions.correction.approve ), reject: useEventAction( - utils.event.actions.correct.reject, - api.event.actions.correct.reject + utils.event.actions.correction.reject, + api.event.actions.correction.reject ) } } diff --git a/packages/client/src/v2-events/features/workqueues/Workqueue.tsx b/packages/client/src/v2-events/features/workqueues/Workqueue.tsx index 0f53a2168d7..d6af7f91023 100644 --- a/packages/client/src/v2-events/features/workqueues/Workqueue.tsx +++ b/packages/client/src/v2-events/features/workqueues/Workqueue.tsx @@ -23,6 +23,7 @@ import { EventConfig, EventIndex, getAllFields, + getOrThrow, RootWorkqueueConfig, workqueues } from '@opencrvs/commons/client' @@ -50,14 +51,6 @@ const messages = defineMessages({ } }) -function getOrThrow(x: T, message: string) { - if (x === undefined || x === null) { - throw new Error(message) - } - - return x -} - /** * Based on packages/client/src/views/OfficeHome/requiresUpdate/RequiresUpdate.tsx and others in the same directory. * Ideally we could use a single component for a workqueue. @@ -264,14 +257,11 @@ function Workqueue({ } function getDefaultColumns(): Array { - // @TODO: Markus should update the types - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return workqueueConfig.defaultColumns.map( (column): Column => ({ label: column in defaultColumns ? intl.formatMessage( - // eslint-disable-next-line defaultColumns[column as keyof typeof defaultColumns].label ) : '', @@ -286,9 +276,7 @@ function Workqueue({ // @TODO: separate types for action button vs other columns function getColumns(): Array { if (width > theme.grid.breakpoints.lg) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return workqueueConfig.columns.map((column) => ({ - // eslint-disable-next-line label: intl.formatMessage(column.label), width: 35, key: column.id, @@ -296,10 +284,8 @@ function Workqueue({ isSorted: sortedCol === column.id })) } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return workqueueConfig.columns .map((column) => ({ - // eslint-disable-next-line label: intl.formatMessage(column.label), width: 35, key: column.id, diff --git a/packages/client/src/v2-events/hooks/usePrintableCertificate.ts b/packages/client/src/v2-events/hooks/usePrintableCertificate.ts index c207a440438..2e7662d6bcf 100644 --- a/packages/client/src/v2-events/hooks/usePrintableCertificate.ts +++ b/packages/client/src/v2-events/hooks/usePrintableCertificate.ts @@ -32,7 +32,6 @@ import { fetchImageAsBase64 } from '@client/utils/imageUtils' async function replaceMinioUrlWithBase64(template: Record) { async function recursiveTransform(obj: any) { if (typeof obj !== 'object' || obj === null) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return obj } @@ -49,7 +48,6 @@ async function replaceMinioUrlWithBase64(template: Record) { } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return transformedObject } return recursiveTransform(template) diff --git a/packages/client/src/views/PrintCertificate/utils.ts b/packages/client/src/views/PrintCertificate/utils.ts index 65f4288722b..63779e01257 100644 --- a/packages/client/src/views/PrintCertificate/utils.ts +++ b/packages/client/src/views/PrintCertificate/utils.ts @@ -9,7 +9,7 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ import { IFormData, IFormSectionGroup, ISelectOption } from '@client/forms' -import { Event, EventType } from '@client/utils/gateway' +import { EventType } from '@client/utils/gateway' import { dynamicMessages } from '@client/i18n/messages/views/certificate' import { getAvailableLanguages } from '@client/i18n/utils' import { ILanguageState } from '@client/i18n/reducer' diff --git a/packages/commons/src/client.ts b/packages/commons/src/client.ts index f28f93dfacb..52969efd1f1 100644 --- a/packages/commons/src/client.ts +++ b/packages/commons/src/client.ts @@ -16,6 +16,7 @@ export * from './conditionals/validate' export * from './documents' export * from './workqueues' export * from './uuid' +export * from './utils' export { DEFAULT_ROLES_DEFINITION } from './authentication' export type PartialBy = Omit & Partial> diff --git a/packages/commons/src/events/utils.ts b/packages/commons/src/events/utils.ts index 439a4d2280d..8dc320e3ef6 100644 --- a/packages/commons/src/events/utils.ts +++ b/packages/commons/src/events/utils.ts @@ -140,3 +140,14 @@ export function validateWorkqueueConfig(workqueueConfigs: WorkqueueConfig[]) { ) }) } + +export const findActiveActionFields = ( + configuration: EventConfig, + action: ActionType +) => { + const actionConfig = configuration.actions.find((a) => a.type === action) + const form = actionConfig?.forms.find((f) => f.active) + + /** Let caller decide whether to throw or default to empty array */ + return form?.pages.flatMap((p) => p.fields) +} diff --git a/packages/commons/src/fixtures/tennis-club-membership-event.ts b/packages/commons/src/fixtures/tennis-club-membership-event.ts index f525ca447c6..ff17623e842 100644 --- a/packages/commons/src/fixtures/tennis-club-membership-event.ts +++ b/packages/commons/src/fixtures/tennis-club-membership-event.ts @@ -8,7 +8,119 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { defineConfig } from '../events' +import { defineConfig, defineForm } from '../events' + +const TENNIS_CLUB_FORM = defineForm({ + label: { + id: 'event.tennis-club-membership.action.declare.form.label', + defaultMessage: 'Tennis club membership application', + description: 'This is what this form is referred as in the system' + }, + active: true, + version: { + id: '1.0.0', + label: { + id: 'event.tennis-club-membership.action.declare.form.version.1', + defaultMessage: 'Version 1', + description: 'This is the first version of the form' + } + }, + review: { + title: { + id: 'event.tennis-club-membership.action.declare.form.review.title', + defaultMessage: 'Member declaration for {firstname} {surname}', + description: 'Title of the form to show in review page' + } + }, + pages: [ + { + id: 'applicant', + title: { + id: 'event.tennis-club-membership.action.declare.form.section.who.title', + defaultMessage: 'Who is applying for the membership?', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'applicant.firstname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Applicant's first name", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.who.field.firstname.label' + } + }, + { + id: 'applicant.surname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Applicant's surname", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.who.field.surname.label' + } + }, + { + id: 'applicant.dob', + type: 'DATE', + required: true, + conditionals: [], + label: { + defaultMessage: "Applicant's date of birth", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.who.field.dob.label' + } + } + ] + }, + { + id: 'recommender', + title: { + id: 'event.tennis-club-membership.action.declare.form.section.recommender.title', + defaultMessage: 'Who is recommending the applicant?', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'recommender.firstname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Recommender's first name", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.firstname.label' + } + }, + { + id: 'recommender.surname', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Recommender's surname", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.surname.label' + } + }, + { + id: 'recommender.id', + type: 'TEXT', + required: true, + conditionals: [], + label: { + defaultMessage: "Recommender's membership ID", + description: 'This is the label for the field', + id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.id.label' + } + } + ] + } + ] +}) export const tennisClubMembershipEvent = defineConfig({ id: 'TENNIS_CLUB_MEMBERSHIP', @@ -116,119 +228,254 @@ export const tennisClubMembershipEvent = defineConfig({ 'This is shown as the action name anywhere the user can trigger the action from', id: 'event.tennis-club-membership.action.declare.label' }, - forms: [ + forms: [TENNIS_CLUB_FORM] + }, + { + type: 'REGISTER', + label: { + defaultMessage: 'Send an application', + description: + 'This is shown as the action name anywhere the user can trigger the action from', + id: 'event.tennis-club-membership.action.declare.label' + }, + forms: [TENNIS_CLUB_FORM] + }, + { + type: 'VALIDATE', + label: { + defaultMessage: 'Validate', + description: + 'This is shown as the action name anywhere the user can trigger the action from', + id: 'event.tennis-club-membership.action.validate.label' + }, + forms: [TENNIS_CLUB_FORM] + }, + { + type: 'REQUEST_CORRECTION', + label: { + defaultMessage: 'Request correction', + description: + 'This is shown as the action name anywhere the user can trigger the action from', + id: 'event.tennis-club-membership.action.correction.request.label' + }, + + forms: [TENNIS_CLUB_FORM], + onboardingForm: [ { - label: { - id: 'event.tennis-club-membership.action.declare.form.label', - defaultMessage: 'Tennis club membership application', - description: 'This is what this form is referred as in the system' + id: 'correction-requester', + title: { + id: 'event.tennis-club-membership.action.requestCorrection.form.section.corrector', + defaultMessage: 'Correction requester', + description: 'This is the title of the section' }, - active: true, - version: { - id: '1.0.0', - label: { - id: 'event.tennis-club-membership.action.declare.form.version.1', - defaultMessage: 'Version 1', - description: 'This is the first version of the form' - } - }, - review: { - title: { - id: 'event.tennis-club-membership.action.declare.form.review.title', - defaultMessage: 'Member declaration for {firstname} {surname}', - description: 'Title of the form to show in review page' - } - }, - pages: [ + fields: [ { - id: 'applicant', - title: { - id: 'event.tennis-club-membership.action.declare.form.section.who.title', - defaultMessage: 'Who is applying for the membership?', - description: 'This is the title of the section' + id: 'correction.requester.relationshop.intro', + type: 'PAGE_HEADER', + label: { + id: 'correction.requester.relationshop.intro.label', + defaultMessage: + 'Note: In the case that the child is now of legal age (18) then only they should be able to request a change to their birth record.', + description: 'The title for the corrector form' + } + }, + { + id: 'correction.requester.relationship', + type: 'RADIO_GROUP', + options: {}, + label: { + id: 'v2.correction.corrector.title', + defaultMessage: 'Who is requesting a change to this record?', + description: 'The title for the corrector form' }, - fields: [ + initialValue: '', + optionValues: [ { - id: 'applicant.firstname', - type: 'TEXT', - required: true, - conditionals: [], + value: 'INFORMANT', label: { - defaultMessage: "Applicant's first name", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.who.field.firstname.label' + id: 'v2.correction.corrector.informant', + defaultMessage: 'Informant', + description: + 'Label for informant option in certificate correction form' } }, { - id: 'applicant.surname', - type: 'TEXT', - required: true, - conditionals: [], + value: 'ANOTHER_AGENT', label: { - defaultMessage: "Applicant's surname", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.who.field.surname.label' + id: 'v2.correction.corrector.anotherAgent', + defaultMessage: 'Another registration agent or field agent', + description: + 'Label for another registration or field agent option in certificate correction form' } }, { - id: 'applicant.dob', - type: 'DATE', - required: true, - conditionals: [], + value: 'REGISTRAR', label: { - defaultMessage: "Applicant's date of birth", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.who.field.dob.label' + id: 'v2.correction.corrector.me', + defaultMessage: 'Me (Registrar)', + description: + 'Label for registrar option in certificate correction form' + } + }, + { + value: 'OTHER', + label: { + id: 'v2.correction.corrector.others', + defaultMessage: 'Someone else', + description: + 'Label for someone else option in certificate correction form' } } ] + } + ] + }, + { + id: 'identity-check', + title: { + id: 'event.tennis-club-membership.action.requestCorrection.form.section.verify', + defaultMessage: 'Verify their identity', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'correction.identity-check.instructions', + type: 'PAGE_HEADER', + label: { + id: 'correction.corrector.identity.instruction', + defaultMessage: + 'Please verify the identity of the person making this request', + description: 'The title for the corrector form' + } }, { - id: 'recommender', - title: { - id: 'event.tennis-club-membership.action.declare.form.section.recommender.title', - defaultMessage: 'Who is recommending the applicant?', - description: 'This is the title of the section' + id: 'correction.identity-check.verified', + type: 'RADIO_GROUP', + options: {}, + label: { + id: 'correction.corrector.identity.verified.label', + defaultMessage: 'Identity verified', + description: 'The title for the corrector form' }, - fields: [ + initialValue: '', + required: true, + optionValues: [ { - id: 'recommender.firstname', - type: 'TEXT', - required: true, - conditionals: [], + value: 'VERIFIED', label: { - defaultMessage: "Recommender's first name", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.firstname.label' + id: 'correction.corrector.identity.verified', + defaultMessage: 'I have verified their identity', + description: + 'Label for verified option in corrector identity check page' } - }, + } + ] + } + ] + } + ], + additionalDetailsForm: [ + { + id: 'correction-request.supporting-documents', + title: { + id: 'event.tennis-club-membership.action.requestCorrection.form.section.verify', + defaultMessage: 'Upload supporting documents', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'correction.supportingDocs.introduction', + type: 'PAGE_HEADER', + label: { + id: 'correction.corrector.paragraph.title', + defaultMessage: + 'For all record corrections at a minimum an affidavit must be provided. For material errors and omissions eg. in paternity cases, a court order must also be provided.', + description: 'The title for the corrector form' + } + }, + { + id: 'correction.supportingDocs', + type: 'FILE', + label: { + id: 'correction.corrector.title', + defaultMessage: 'Upload supporting documents', + description: 'The title for the corrector form' + } + }, + { + id: 'correction.request.supportingDocuments', + type: 'RADIO_GROUP', + label: { + id: 'correction.corrector.title', + defaultMessage: 'Who is requesting a change to this record?', + description: 'The title for the corrector form' + }, + initialValue: '', + options: { + size: 'NORMAL' + }, + optionValues: [ { - id: 'recommender.surname', - type: 'TEXT', - required: true, - conditionals: [], + value: 'ATTEST', label: { - defaultMessage: "Recommender's surname", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.surname.label' + id: 'correction.supportingDocuments.attest.label', + defaultMessage: + 'I attest to seeing supporting documentation and have a copy filed at my office', + description: '' } }, { - id: 'recommender.id', - type: 'TEXT', - required: true, - conditionals: [], + value: 'NOT_NEEDED', label: { - defaultMessage: "Recommender's membership ID", - description: 'This is the label for the field', - id: 'event.tennis-club-membership.action.declare.form.section.recommender.field.id.label' + id: 'correction.supportingDocuments.notNeeded.label', + defaultMessage: 'No supporting documents required', + description: '' } } ] } ] + }, + { + id: 'correction-request.additional-details', + title: { + id: 'event.tennis-club-membership.action.requestCorrection.form.section.corrector', + defaultMessage: 'Reason for correction', + description: 'This is the title of the section' + }, + fields: [ + { + id: 'correction.request.reason', + type: 'TEXT', + label: { + id: 'correction.reason.title', + defaultMessage: 'Reason for correction?', + description: 'The title for the corrector form' + } + } + ] } ] + }, + { + type: 'APPROVE_CORRECTION', + forms: [TENNIS_CLUB_FORM], + label: { + defaultMessage: 'Approve correction', + description: + 'This is shown as the action name anywhere the user can trigger the action from', + id: 'event.tennis-club-membership.action.correction.approve.label' + } + }, + { + type: 'PRINT_CERTIFICATE', + label: { + defaultMessage: 'Print certificate', + description: + 'This is shown as the action name anywhere the user can trigger the action from', + id: 'event.tennis-club-membership.action.collect-certificate.label' + }, + forms: [TENNIS_CLUB_FORM] } ] }) diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 5cc2b0964cb..46a98ca4174 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -18,3 +18,4 @@ export * from './search' export * from './events' export * from './users/service' export * from './authentication' +export * from './utils' diff --git a/packages/commons/src/utils.ts b/packages/commons/src/utils.ts new file mode 100644 index 00000000000..5a305f3b684 --- /dev/null +++ b/packages/commons/src/utils.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export function getOrThrow(x: T, message: string) { + if (x === undefined || x === null) { + throw new Error(message) + } + + return x +} diff --git a/packages/events/README.md b/packages/events/README.md new file mode 100644 index 00000000000..bbde8057d39 --- /dev/null +++ b/packages/events/README.md @@ -0,0 +1,21 @@ +# Events + +Service for managing custom events + +## Development practices + +[See client README](../client/src/v2-events/README.md) + +## Glossary + +[See client GLOSSARY](../client/src/v2-events/GLOSSARY.md) + +## Styleguide + +[See client STYLEGUIDE](../client/src/v2-events/STYLEGUIDE.md) + +## Testing + +- Each endpoint should be tested +- Global test setup expects [tennis-club-membership-event fixture](../commons/src/fixtures/tennis-club-membership-event.ts) to be returned from country-configuration +- Test generators rely on [tennis-club-membership-event fixture](../commons/src/fixtures/tennis-club-membership-event.ts) to dynamically generate action data (validation is performed based on the dynamic configuration) diff --git a/packages/events/src/router/event/__snapshots__/event.actions.correction.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.actions.correction.test.ts.snap new file mode 100644 index 00000000000..7b8691a6c76 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.actions.correction.test.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`APPROVE_CORRECTION validation error message contains all the offending fields 1`] = `[TRPCError: [{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.surname"],"message":"Required"},{"code":"invalid_string","validation":"date","message":"Invalid date","path":["applicant.dob"]},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.surname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.id"],"message":"Required"}]]`; + +exports[`REQUEST_CORRECTION validation error message contains all the offending fields 1`] = `[TRPCError: [{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.surname"],"message":"Required"},{"code":"invalid_string","validation":"date","message":"Invalid date","path":["applicant.dob"]},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.surname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.id"],"message":"Required"}]]`; diff --git a/packages/events/src/router/event/__snapshots__/event.actions.declare.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.actions.declare.test.ts.snap new file mode 100644 index 00000000000..4b84f6c5ae6 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.actions.declare.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Validation error message contains all the offending fields 1`] = `[TRPCError: [{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.surname"],"message":"Required"},{"code":"invalid_string","validation":"date","message":"Invalid date","path":["applicant.dob"]},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.surname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.id"],"message":"Required"}]]`; diff --git a/packages/events/src/router/event/__snapshots__/event.actions.printCertificate.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.actions.printCertificate.test.ts.snap new file mode 100644 index 00000000000..4b84f6c5ae6 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.actions.printCertificate.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Validation error message contains all the offending fields 1`] = `[TRPCError: [{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.surname"],"message":"Required"},{"code":"invalid_string","validation":"date","message":"Invalid date","path":["applicant.dob"]},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.surname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.id"],"message":"Required"}]]`; diff --git a/packages/events/src/router/event/__snapshots__/event.actions.register.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.actions.register.test.ts.snap new file mode 100644 index 00000000000..4b84f6c5ae6 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.actions.register.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Validation error message contains all the offending fields 1`] = `[TRPCError: [{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.surname"],"message":"Required"},{"code":"invalid_string","validation":"date","message":"Invalid date","path":["applicant.dob"]},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.surname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.id"],"message":"Required"}]]`; diff --git a/packages/events/src/router/event/__snapshots__/event.actions.validate.test.ts.snap b/packages/events/src/router/event/__snapshots__/event.actions.validate.test.ts.snap new file mode 100644 index 00000000000..4b84f6c5ae6 --- /dev/null +++ b/packages/events/src/router/event/__snapshots__/event.actions.validate.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Validation error message contains all the offending fields 1`] = `[TRPCError: [{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["applicant.surname"],"message":"Required"},{"code":"invalid_string","validation":"date","message":"Invalid date","path":["applicant.dob"]},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.firstname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.surname"],"message":"Required"},{"code":"invalid_type","expected":"string","received":"undefined","path":["recommender.id"],"message":"Required"}]]`; diff --git a/packages/events/src/router/event/event.actions.correction.test.ts b/packages/events/src/router/event/event.actions.correction.test.ts index ea9ae671886..d0b99cf44d6 100644 --- a/packages/events/src/router/event/event.actions.correction.test.ts +++ b/packages/events/src/router/event/event.actions.correction.test.ts @@ -16,6 +16,8 @@ import { getUUID } from '@opencrvs/commons' import { createTestClient, setupTestCase } from '@events/tests/utils' +import { generateActionInput } from '@events/tests/generators' +import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' test('a correction request can be added to a created event', async () => { const { user, generator } = await setupTestCase() @@ -23,23 +25,80 @@ test('a correction request can be added to a created event', async () => { const originalEvent = await client.event.create(generator.event.create()) - await client.event.actions.declare( - generator.event.actions.declare(originalEvent.id, { + const declareInput = generator.event.actions.declare(originalEvent.id) + + await client.event.actions.declare(declareInput) + const registeredEvent = await client.event.actions.register( + generator.event.actions.register(originalEvent.id) + ) + + const withCorrectionRequest = await client.event.actions.correction.request( + generator.event.actions.correction.request(registeredEvent.id) + ) + + expect( + withCorrectionRequest.actions[withCorrectionRequest.actions.length - 1].type + ).toBe(ActionType.REQUEST_CORRECTION) +}) + +test(`${ActionType.REQUEST_CORRECTION} validation error message contains all the offending fields`, async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + event.id + + const data = generator.event.actions.correction.request(event.id, { + data: { + 'applicant.dob': '02-02' + } + }) + + await expect( + client.event.actions.correction.request(data) + ).rejects.matchSnapshot() +}) + +test(`${ActionType.APPROVE_CORRECTION} validation error message contains all the offending fields`, async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + + const withCorrectionRequest = await client.event.actions.correction.request( + generator.event.actions.correction.request(event.id) + ) + + const data = generator.event.actions.correction.approve( + event.id, + withCorrectionRequest.id, + { data: { - name: 'John Doe' + 'applicant.dob': '02-02' } - }) + } + ) + + await expect( + client.event.actions.correction.approve(data) + ).rejects.matchSnapshot() +}) + +test('a correction request can be added to a created event', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const originalEvent = await client.event.create(generator.event.create()) + + await client.event.actions.declare( + generator.event.actions.declare(originalEvent.id) ) const registeredEvent = await client.event.actions.register( generator.event.actions.register(originalEvent.id) ) - const withCorrectionRequest = await client.event.actions.correct.request( - generator.event.actions.correct.request(registeredEvent.id, { - data: { - name: 'Doe John' - } - }) + const withCorrectionRequest = await client.event.actions.correction.request( + generator.event.actions.correction.request(registeredEvent.id) ) expect( @@ -57,21 +116,22 @@ describe('when a correction request exists', () => { const originalEvent = await client.event.create(generator.event.create()) - await client.event.actions.declare( - generator.event.actions.declare(originalEvent.id, { - data: { - name: 'John Doe' - } - }) - ) + const declareInput = generator.event.actions.declare(originalEvent.id) + + await client.event.actions.declare(declareInput) + const registeredEvent = await client.event.actions.register( generator.event.actions.register(originalEvent.id) ) - withCorrectionRequest = await client.event.actions.correct.request( - generator.event.actions.correct.request(registeredEvent.id, { + withCorrectionRequest = await client.event.actions.correction.request( + generator.event.actions.correction.request(registeredEvent.id, { data: { - name: 'Doe John' + ...generateActionInput( + tennisClubMembershipEvent, + ActionType.REQUEST_CORRECTION + ), + 'applicant.firstName': 'Johnny' } }) ) @@ -84,11 +144,10 @@ describe('when a correction request exists', () => { withCorrectionRequest.actions[withCorrectionRequest.actions.length - 1].id const withApprovedCorrectionRequest = - await client.event.actions.correct.approve( - generator.event.actions.correct.approve( + await client.event.actions.correction.approve( + generator.event.actions.correction.approve( withCorrectionRequest.id, - requestId, - {} + requestId ) ) @@ -106,11 +165,10 @@ describe('when a correction request exists', () => { const incorrectRequestId = getUUID() - const request = client.event.actions.correct.approve( - generator.event.actions.correct.approve( + const request = client.event.actions.correction.approve( + generator.event.actions.correction.approve( withCorrectionRequest.id, - incorrectRequestId, - {} + incorrectRequestId ) ) await expect(request).rejects.toThrow() diff --git a/packages/events/src/router/event/event.actions.declare.test.ts b/packages/events/src/router/event/event.actions.declare.test.ts new file mode 100644 index 00000000000..355f7694373 --- /dev/null +++ b/packages/events/src/router/event/event.actions.declare.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { createTestClient, setupTestCase } from '@events/tests/utils' + +test('Validation error message contains all the offending fields', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + event.id + + const data = generator.event.actions.declare(event.id, { + data: { + 'applicant.dob': '02-02' + } + }) + + await expect(client.event.actions.declare(data)).rejects.matchSnapshot() +}) diff --git a/packages/events/src/router/event/event.print.test.ts b/packages/events/src/router/event/event.actions.printCertificate.test.ts similarity index 60% rename from packages/events/src/router/event/event.print.test.ts rename to packages/events/src/router/event/event.actions.printCertificate.test.ts index e3f2372085a..b7e5f9d7dd6 100644 --- a/packages/events/src/router/event/event.print.test.ts +++ b/packages/events/src/router/event/event.actions.printCertificate.test.ts @@ -12,29 +12,41 @@ import { ActionType } from '@opencrvs/commons' import { createTestClient, setupTestCase } from '@events/tests/utils' -test('a correction request can be added to a created event', async () => { +test('Validation error message contains all the offending fields', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + + await expect( + client.event.actions.printCertificate( + generator.event.actions.printCertificate(event.id, { + data: { + 'applicant.dob': '02-02' + } + }) + ) + ).rejects.matchSnapshot() +}) + +test('print certificate action can be added to a created event', async () => { const { user, generator } = await setupTestCase() const client = createTestClient(user) const originalEvent = await client.event.create(generator.event.create()) await client.event.actions.declare( - generator.event.actions.declare(originalEvent.id, { - data: { - name: 'John Doe' - } - }) + generator.event.actions.declare(originalEvent.id) ) const registeredEvent = await client.event.actions.register( generator.event.actions.register(originalEvent.id) ) const printCertificate = await client.event.actions.printCertificate( - generator.event.actions.printCertificate(registeredEvent.id, { - data: { - selectedTemplateId: 'certified-certificate-template' - } - }) + generator.event.actions.printCertificate( + registeredEvent.id, + generator.event.actions.printCertificate(registeredEvent.id) + ) ) expect( diff --git a/packages/events/src/router/event/event.actions.register.test.ts b/packages/events/src/router/event/event.actions.register.test.ts new file mode 100644 index 00000000000..4fa1d5c9642 --- /dev/null +++ b/packages/events/src/router/event/event.actions.register.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { createTestClient, setupTestCase } from '@events/tests/utils' + +test('Validation error message contains all the offending fields', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + event.id + + const data = generator.event.actions.register(event.id, { + data: { + 'applicant.dob': '02-02' + } + }) + + await expect(client.event.actions.register(data)).rejects.matchSnapshot() +}) diff --git a/packages/events/src/router/event/event.actions.test.ts b/packages/events/src/router/event/event.actions.test.ts index 1d1cd49e7ff..e1a359df6db 100644 --- a/packages/events/src/router/event/event.actions.test.ts +++ b/packages/events/src/router/event/event.actions.test.ts @@ -34,12 +34,8 @@ test('Action data can be retrieved', async () => { const originalEvent = await client.event.create(generator.event.create()) - const data = { name: 'John Doe', favouriteFruit: 'Banana' } - await client.event.actions.declare( - generator.event.actions.declare(originalEvent.id, { - data - }) - ) + const generatedDeclaration = generator.event.actions.declare(originalEvent.id) + await client.event.actions.declare(generatedDeclaration) const updatedEvent = await client.event.get(originalEvent.id) @@ -47,7 +43,7 @@ test('Action data can be retrieved', async () => { expect.objectContaining({ type: ActionType.CREATE }), expect.objectContaining({ type: ActionType.DECLARE, - data + data: generatedDeclaration.data }) ]) }) diff --git a/packages/events/src/router/event/event.actions.validate.test.ts b/packages/events/src/router/event/event.actions.validate.test.ts new file mode 100644 index 00000000000..4fa1d5c9642 --- /dev/null +++ b/packages/events/src/router/event/event.actions.validate.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { createTestClient, setupTestCase } from '@events/tests/utils' + +test('Validation error message contains all the offending fields', async () => { + const { user, generator } = await setupTestCase() + const client = createTestClient(user) + + const event = await client.event.create(generator.event.create()) + event.id + + const data = generator.event.actions.register(event.id, { + data: { + 'applicant.dob': '02-02' + } + }) + + await expect(client.event.actions.register(data)).rejects.matchSnapshot() +}) diff --git a/packages/events/src/router/event/event.get.test.ts b/packages/events/src/router/event/event.get.test.ts index ec6a624c723..6c6c1fe8a42 100644 --- a/packages/events/src/router/event/event.get.test.ts +++ b/packages/events/src/router/event/event.get.test.ts @@ -10,6 +10,7 @@ */ import { createTestClient, setupTestCase } from '@events/tests/utils' +import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' test('Returns 404 when not found', async () => { const { user } = await setupTestCase() @@ -37,17 +38,33 @@ test('Returns event with all actions', async () => { const event = await client.event.create(generator.event.create()) - await client.event.actions.declare( - generator.event.actions.declare(event.id, { data: { name: 'John Doe' } }) + await client.event.actions.declare(generator.event.actions.declare(event.id)) + + await client.event.actions.register( + generator.event.actions.register(event.id) + ) + + await client.event.actions.printCertificate( + generator.event.actions.printCertificate(event.id) + ) + const correctionRequest = await client.event.actions.correction.request( + generator.event.actions.correction.request(event.id) ) + await client.event.actions.correction.approve( + generator.event.actions.correction.approve( + correctionRequest.id, + correctionRequest.actions[correctionRequest.actions.length - 1].id + ) + ) await client.event.actions.validate( - generator.event.actions.validate(event.id, { - data: { favouritePlayer: 'Elena Rybakina' } - }) + generator.event.actions.validate(event.id) ) const fetchedEvent = await client.event.get(event.id) - expect(fetchedEvent.actions).toHaveLength(3) + // should throw when test is not updated after updating fixture or something breaks. + expect(fetchedEvent.actions).toHaveLength( + tennisClubMembershipEvent.actions.length + 1 // CREATE EVENT + ) }) diff --git a/packages/events/src/router/event/event.list.test.ts b/packages/events/src/router/event/event.list.test.ts index 8ca23766f88..dd0ab6141a2 100644 --- a/packages/events/src/router/event/event.list.test.ts +++ b/packages/events/src/router/event/event.list.test.ts @@ -38,7 +38,15 @@ test('Returns aggregated event with updated status and values', async () => { const { user, generator } = await setupTestCase() const client = createTestClient(user) - const initialData = { name: 'John Doe', favouriteFruit: 'Banana' } + const initialData = { + 'applicant.firstname': 'John', + 'applicant.surname': 'Doe', + 'applicant.dob': '2000-01-01', + 'recommender.firstname': 'Jane', + 'recommender.surname': 'Doer', + 'recommender.id': '123-124' + } + const event = await client.event.create(generator.event.create()) await client.event.actions.declare( generator.event.actions.declare(event.id, { @@ -52,7 +60,7 @@ test('Returns aggregated event with updated status and values', async () => { expect(initialEvents[0].status).toBe(EventStatus.DECLARED) expect(initialEvents[0].data).toEqual(initialData) - const updatedData = { name: 'John Doe', favouriteFruit: 'Strawberry' } + const updatedData = { ...initialData, 'recommender.firstname': 'Yane' } await client.event.actions.declare( generator.event.actions.declare(event.id, { data: updatedData diff --git a/packages/events/src/router/event/index.ts b/packages/events/src/router/event/index.ts index c46f1e31fe3..18a2610bf39 100644 --- a/packages/events/src/router/event/index.ts +++ b/packages/events/src/router/event/index.ts @@ -32,6 +32,7 @@ import { logger } from '@opencrvs/commons' import { + ActionType, PrintCertificateActionInput, DeclareActionInput, EventIndex, @@ -44,6 +45,7 @@ import { import { router, publicProcedure } from '@events/router/trpc' import { approveCorrection } from '@events/service/events/actions/approve-correction' import { rejectCorrection } from '@events/service/events/actions/reject-correction' +import * as middleware from '@events/router/middleware' function validateEventType({ eventTypes, @@ -114,6 +116,7 @@ export const eventRouter = router({ actions: router({ notify: publicProcedure .input(NotifyActionInput) + .use(middleware.validateAction(ActionType.NOTIFY)) .mutation(async (options) => { return addAction(options.input, { eventId: options.input.eventId, @@ -125,6 +128,7 @@ export const eventRouter = router({ }), declare: publicProcedure .input(DeclareActionInput) + .use(middleware.validateAction(ActionType.DECLARE)) .mutation(async (options) => { return addAction(options.input, { eventId: options.input.eventId, @@ -136,6 +140,7 @@ export const eventRouter = router({ }), validate: publicProcedure .input(ValidateActionInput) + .use(middleware.validateAction(ActionType.VALIDATE)) .mutation(async (options) => { return addAction(options.input, { eventId: options.input.eventId, @@ -146,7 +151,10 @@ export const eventRouter = router({ }) }), register: publicProcedure + // @TODO: Find out a way to dynamically modify the MiddlewareOptions type .input(RegisterActionInput.omit({ identifiers: true })) + // @ts-expect-error + .use(middleware.validateAction(ActionType.REGISTER)) .mutation(async (options) => { return addAction( { @@ -167,6 +175,7 @@ export const eventRouter = router({ }), printCertificate: publicProcedure .input(PrintCertificateActionInput) + .use(middleware.validateAction(ActionType.PRINT_CERTIFICATE)) .mutation(async (options) => { return addAction(options.input, { eventId: options.input.eventId, @@ -176,9 +185,10 @@ export const eventRouter = router({ transactionId: options.input.transactionId }) }), - correct: router({ + correction: router({ request: publicProcedure .input(RequestCorrectionActionInput) + .use(middleware.validateAction(ActionType.REQUEST_CORRECTION)) .mutation(async (options) => { return addAction(options.input, { eventId: options.input.eventId, @@ -190,6 +200,7 @@ export const eventRouter = router({ }), approve: publicProcedure .input(ApproveCorrectionActionInput) + .use(middleware.validateAction(ActionType.APPROVE_CORRECTION)) .mutation(async (options) => { return approveCorrection(options.input, { eventId: options.input.eventId, diff --git a/packages/events/src/router/locations/index.ts b/packages/events/src/router/locations/index.ts index 7a2b2010ddc..30e42736298 100644 --- a/packages/events/src/router/locations/index.ts +++ b/packages/events/src/router/locations/index.ts @@ -10,7 +10,7 @@ */ import { router, publicProcedure } from '@events/router/trpc' -import { middleware } from '@events/router/middleware/middleware' +import * as middleware from '@events/router/middleware' import { getLocations, Location, diff --git a/packages/events/src/router/middleware/middleware.ts b/packages/events/src/router/middleware/authorization/index.ts similarity index 62% rename from packages/events/src/router/middleware/middleware.ts rename to packages/events/src/router/middleware/authorization/index.ts index 90582280af6..3a517e54c46 100644 --- a/packages/events/src/router/middleware/middleware.ts +++ b/packages/events/src/router/middleware/authorization/index.ts @@ -9,29 +9,10 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { inScope, Scope, SCOPES, TokenWithBearer } from '@opencrvs/commons' -import { TRPCError, AnyTRPCMiddlewareFunction } from '@trpc/server' +import { inScope, Scope, SCOPES } from '@opencrvs/commons' +import { TRPCError } from '@trpc/server' -import { z } from 'zod' - -const ContextSchema = z.object({ - user: z.object({ - id: z.string(), - primaryOfficeId: z.string() - }), - token: z.string() as z.ZodType -}) - -export type Context = z.infer - -/** - * TRPC Middleware options with correct context. - * Actual middleware type definition is only for internal use within TRPC. - */ -type MiddlewareOptions = Omit< - Parameters[0], - 'ctx' -> & { ctx: Context } +import { MiddlewareOptions } from '@events/router/middleware/utils' /** * Depending on how the API is called, there might or might not be Bearer keyword in the header. @@ -42,6 +23,7 @@ function setBearerForToken(token: string) { return token.startsWith(bearer) ? token : `${bearer} ${token}` } + /** * @param scopes scopes that are allowed to access the resource * @returns TRPC compatible middleware function @@ -56,8 +38,6 @@ function createScopeAuthMiddleware(scopes: Scope[]) { } } -const isDataSeedingUser = createScopeAuthMiddleware([SCOPES.USER_DATA_SEEDING]) - -export const middleware = { - isDataSeedingUser -} +export const isDataSeedingUser = createScopeAuthMiddleware([ + SCOPES.USER_DATA_SEEDING +]) diff --git a/packages/events/src/router/middleware/index.ts b/packages/events/src/router/middleware/index.ts new file mode 100644 index 00000000000..c736f5b25c4 --- /dev/null +++ b/packages/events/src/router/middleware/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export * from './authorization' +export * from './validate' +export * from './utils' diff --git a/packages/events/src/router/middleware/utils.ts b/packages/events/src/router/middleware/utils.ts new file mode 100644 index 00000000000..087324f9151 --- /dev/null +++ b/packages/events/src/router/middleware/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 { TokenWithBearer } from '@opencrvs/commons' +import { AnyTRPCMiddlewareFunction } from '@trpc/server' + +import { z } from 'zod' + +const ContextSchema = z.object({ + user: z.object({ + id: z.string(), + primaryOfficeId: z.string() + }), + token: z.string() as z.ZodType +}) + +export type Context = z.infer + +/** + * TRPC Middleware options with correct context. + * Actual middleware type definition is only for internal use within TRPC. + */ +export type MiddlewareOptions = Omit< + Parameters[0], + 'ctx' +> & { ctx: Context } diff --git a/packages/events/src/router/middleware/validate/index.ts b/packages/events/src/router/middleware/validate/index.ts new file mode 100644 index 00000000000..134e3cf9861 --- /dev/null +++ b/packages/events/src/router/middleware/validate/index.ts @@ -0,0 +1,92 @@ +/* + * 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 { + ActionInputWithType, + ActionType, + FieldConfig, + FileFieldValue +} from '@opencrvs/commons' + +import { z } from 'zod' +import { getActionFormFields } from '@events/service/config/config' +import { getEventTypeId } from '@events/service/events/events' +import { MiddlewareOptions } from '@events/router/middleware/utils' +import { TRPCError } from '@trpc/server' + +type ActionMiddlewareOptions = Omit & { + input: ActionInputWithType +} + +function mapTypeToZod(type: FieldConfig['type'], required?: boolean) { + let schema + switch (type) { + case 'DIVIDER': + case 'TEXT': + case 'BULLET_LIST': + case 'PAGE_HEADER': + case 'LOCATION': + case 'SELECT': + case 'COUNTRY': + case 'RADIO_GROUP': + case 'PARAGRAPH': + schema = z.string() + break + case 'DATE': + // YYYY-MM-DD + schema = z.string().date() + break + case 'CHECKBOX': + schema = z.boolean() + break + case 'FILE': + schema = FileFieldValue + break + } + + return required ? schema : schema.optional() +} + +type InputField = typeof FileFieldValue | z.ZodString | z.ZodBoolean + +type OptionalInputField = z.ZodOptional +function createValidationSchema(config: FieldConfig[]) { + const shape: Record = {} + + for (const field of config) { + shape[field.id] = mapTypeToZod(field.type, field.required) + } + + return z.object(shape) +} + +export function validateAction(actionType: ActionType) { + return async (opts: ActionMiddlewareOptions) => { + const eventType = await getEventTypeId(opts.input.eventId) + + const formFields = await getActionFormFields({ + token: opts.ctx.token, + action: actionType, + eventType + }) + + const result = createValidationSchema(formFields).safeParse(opts.input.data) + + if (result.error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: JSON.stringify(result.error.errors) + }) + } + + return opts.next() + } +} diff --git a/packages/events/src/router/trpc.ts b/packages/events/src/router/trpc.ts index 663d1e1e175..339a16a1ab6 100644 --- a/packages/events/src/router/trpc.ts +++ b/packages/events/src/router/trpc.ts @@ -10,7 +10,7 @@ */ import { initTRPC } from '@trpc/server' -import { Context } from './middleware/middleware' +import { Context } from './middleware' import superjson from 'superjson' export const t = initTRPC.context().create({ diff --git a/packages/events/src/service/config/config.ts b/packages/events/src/service/config/config.ts index 4e998eec875..1b9bd603ee8 100644 --- a/packages/events/src/service/config/config.ts +++ b/packages/events/src/service/config/config.ts @@ -12,8 +12,12 @@ import { env } from '@events/environment' import { ActionInput, + ActionType, EventConfig, EventDocument, + FieldConfig, + findActiveActionFields, + getOrThrow, logger } from '@opencrvs/commons' import fetch from 'node-fetch' @@ -34,6 +38,41 @@ export async function getEventConfigurations(token: string) { return array(EventConfig).parse(await res.json()) } +async function findEventConfigurationById({ + token, + eventType +}: { + token: string + eventType: string +}) { + const configurations = await getEventConfigurations(token) + + return configurations.find((config) => config.id === eventType) +} + +export async function getActionFormFields({ + token, + eventType, + action +}: { + token: string + eventType: string + action: ActionType +}): Promise { + const configuration = getOrThrow( + await findEventConfigurationById({ + token, + eventType + }), + `No configuration found for event type: ${eventType}` + ) + + return getOrThrow( + findActiveActionFields(configuration, action), + `No fields found for action: ${action}` + ) +} + export async function notifyOnAction( action: ActionInput, event: EventDocument, diff --git a/packages/events/src/service/events/events.ts b/packages/events/src/service/events/events.ts index 44eb287533c..8365a7f00dd 100644 --- a/packages/events/src/service/events/events.ts +++ b/packages/events/src/service/events/events.ts @@ -64,6 +64,12 @@ export async function getEventById(id: string) { return event } +export async function getEventTypeId(id: string) { + const event = await getEventById(id) + + return event.type +} + export async function deleteEvent( eventId: string, { token }: { token: string } diff --git a/packages/events/src/tests/generators.ts b/packages/events/src/tests/generators.ts index d3355cb8d03..8ec9ada6745 100644 --- a/packages/events/src/tests/generators.ts +++ b/packages/events/src/tests/generators.ts @@ -16,10 +16,53 @@ import { ActionType, ValidateActionInput, RegisterActionInput, - RequestCorrectionActionInput + RequestCorrectionActionInput, + findActiveActionFields, + EventConfig, + FieldConfig } from '@opencrvs/commons' import { Location } from '@events/service/locations/locations' import { Db } from 'mongodb' +import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' + +/** + * Quick-and-dirty mock data generator for event actions. + */ +function mapTypeToMockValue(field: FieldConfig, i: number) { + switch (field.type) { + case 'DIVIDER': + case 'TEXT': + case 'BULLET_LIST': + case 'PAGE_HEADER': + case 'LOCATION': + case 'SELECT': + case 'COUNTRY': + case 'RADIO_GROUP': + case 'PARAGRAPH': + return `${field.id}-${field.type}-${i}` + case 'DATE': + return '2021-01-01' + case 'CHECKBOX': + return true + case 'FILE': + return null + } +} + +export function generateActionInput( + configuration: EventConfig, + action: ActionType +) { + const fields = findActiveActionFields(configuration, action) ?? [] + + return fields.reduce( + (acc, field, i) => ({ + ...acc, + [field.id]: mapTypeToMockValue(field, i) + }), + {} + ) +} interface Name { use: string @@ -39,6 +82,7 @@ interface CreateUser { role?: string name?: Array } + /** * @returns a payload generator for creating events and actions with sensible defaults. */ @@ -60,7 +104,9 @@ export function payloadGenerator() { ) => ({ type: ActionType.DECLARE, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput(tennisClubMembershipEvent, ActionType.DECLARE), eventId }), validate: ( @@ -69,7 +115,9 @@ export function payloadGenerator() { ) => ({ type: ActionType.VALIDATE, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput(tennisClubMembershipEvent, ActionType.VALIDATE), duplicates: [], eventId }), @@ -79,7 +127,9 @@ export function payloadGenerator() { ) => ({ type: ActionType.REGISTER, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput(tennisClubMembershipEvent, ActionType.REGISTER), eventId }), printCertificate: ( @@ -88,10 +138,15 @@ export function payloadGenerator() { ) => ({ type: ActionType.PRINT_CERTIFICATE, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput( + tennisClubMembershipEvent, + ActionType.PRINT_CERTIFICATE + ), eventId }), - correct: { + correction: { request: ( eventId: string, input: Partial< @@ -100,7 +155,12 @@ export function payloadGenerator() { ) => ({ type: ActionType.REQUEST_CORRECTION, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput( + tennisClubMembershipEvent, + ActionType.REQUEST_CORRECTION + ), metadata: {}, eventId }), @@ -113,7 +173,12 @@ export function payloadGenerator() { ) => ({ type: ActionType.APPROVE_CORRECTION, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput( + tennisClubMembershipEvent, + ActionType.APPROVE_CORRECTION + ), eventId, requestId }), @@ -126,7 +191,12 @@ export function payloadGenerator() { ) => ({ type: ActionType.REJECT_CORRECTION, transactionId: input.transactionId ?? getUUID(), - data: input.data ?? {}, + data: + input.data ?? + generateActionInput( + tennisClubMembershipEvent, + ActionType.REJECT_CORRECTION + ), eventId, requestId }) diff --git a/packages/events/src/tests/msw.ts b/packages/events/src/tests/msw.ts index c17c34de0e3..459a45952f9 100644 --- a/packages/events/src/tests/msw.ts +++ b/packages/events/src/tests/msw.ts @@ -11,6 +11,7 @@ import { http, HttpResponse, PathParams } from 'msw' import { env } from '@events/environment' import { setupServer } from 'msw/node' +import { tennisClubMembershipEvent } from '@opencrvs/commons/fixtures' const handlers = [ http.post, { filenames: string[] }>( @@ -19,7 +20,13 @@ const handlers = [ const request = await info.request.json() return HttpResponse.json(request.filenames) } - ) + ), + http.get(`${env.COUNTRY_CONFIG_URL}/events`, (info) => { + return HttpResponse.json([ + tennisClubMembershipEvent, + { ...tennisClubMembershipEvent, id: 'TENNIS_CLUB_MEMBERSHIP_PREMIUM' } + ]) + }) ] export const mswServer = setupServer(...handlers) diff --git a/packages/events/src/tests/setup.ts b/packages/events/src/tests/setup.ts index f646577aa1d..6d40e6b4b7f 100644 --- a/packages/events/src/tests/setup.ts +++ b/packages/events/src/tests/setup.ts @@ -21,14 +21,6 @@ vi.mock('@events/storage/mongodb/events') vi.mock('@events/storage/mongodb/user-mgnt') vi.mock('@events/storage/elasticsearch') -vi.mock('@events/service/config/config', () => ({ - notifyOnAction: async () => Promise.resolve(), - getEventConfigurations: async () => - Promise.all([ - tennisClubMembershipEvent, - { ...tennisClubMembershipEvent, id: 'TENNIS_CLUB_MEMBERSHIP_PREMIUM' } - ]) -})) async function resetESServer() { const { getEventIndexName, getEventAliasName } = await import(