diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 7bb1e521315..ce3cb945e96 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -183,7 +183,7 @@ jobs: - name: Run knip on base branch id: knip_base run: | - npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown > knip_report.md + npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown | sed -E 's/ +/ /g' | sed -E 's/:[0-9]+:[0-9]+//' > knip_report.md TOTAL=$(grep -oP '## [A-Za-z\s]+ \(\K[0-9]+' knip_report.md | awk '{sum+=$1} END {print sum}') echo "Total $TOTAL issue(s) on base branch." echo "total=${TOTAL}" >> $GITHUB_OUTPUT @@ -192,7 +192,7 @@ jobs: - name: Run knip on PR branch id: knip_pr run: | - npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown > knip_report.md + npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown | sed -E 's/ +/ /g' | sed -E 's/:[0-9]+:[0-9]+//' > knip_report.md TOTAL=$(grep -oP '## [A-Za-z\s]+ \(\K[0-9]+' knip_report.md | awk '{sum+=$1} END {print sum}') echo "Total $TOTAL issue(s) on PR branch." echo "total=${TOTAL}" >> $GITHUB_OUTPUT diff --git a/docker-compose.yml b/docker-compose.yml index 72a894ac8a3..684e068b593 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,7 @@ services: - MONGO_URL=mongodb://mongo1/events - ES_HOST=elasticsearch:9200 - COUNTRY_CONFIG_URL=http://countryconfig:3040/ + - DOCUMENTS_URL=http://documents:9050 # User facing services workflow: diff --git a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx index 47d0c29c4c8..8ba8ad867ae 100644 --- a/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx +++ b/packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx @@ -8,10 +8,32 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ + /* eslint-disable */ +import { InputField } from '@client/components/form/InputField' +import { DATE, PARAGRAPH, TEXT } from '@client/forms' +import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils' +import { DateField } from '@opencrvs/components/lib/DateField' +import { Text } from '@opencrvs/components/lib/Text' +import { TextInput } from '@opencrvs/components/lib/TextInput' import * as React from 'react' + import styled, { keyframes } from 'styled-components' +import { + evalExpressionInFieldDefinition, + getConditionalActionsForField, + getDependentFields, + handleInitialValue, + hasInitialValueDependencyInfo +} from './utils' +import { Errors, getValidationErrorsForForm } from './validation' +import { + FieldConfig, + FieldValue, + FieldValueByType, + FileFieldValue +} from '@opencrvs/commons/client' import { Field, FieldProps, @@ -26,30 +48,8 @@ import { MessageDescriptor, useIntl } from 'react-intl' -import { FieldConfig } from '@opencrvs/commons' -import { TextInput } from '@opencrvs/components/lib/TextInput' -import { Text } from '@opencrvs/components/lib/Text' -import { DateField } from '@opencrvs/components/lib/DateField' -import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils' -import { - DATE, - HIDDEN, - IFormFieldValue, - IFormSectionData, - PARAGRAPH, - TEXT -} from '@client/forms' -import { InputField } from '@client/components/form/InputField' -import { - evalExpressionInFieldDefinition, - flatten, - getConditionalActionsForField, - getDependentFields, - handleInitialValue, - hasInitialValueDependencyInfo, - unflatten -} from './utils' -import { Errors, getValidationErrorsForForm } from './validation' +import { FileInput } from './inputs/FileInput/FileInput' +import { ActionFormData } from '@opencrvs/commons' const fadeIn = keyframes` from { opacity: 0; } @@ -64,27 +64,27 @@ const FormItem = styled.div<{ ignoreBottomMargin ? '0px' : '22px'}; ` -interface GeneratedInputFieldProps { - fieldDefinition: FieldConfig +interface GeneratedInputFieldProps { + fieldDefinition: FieldType fields: FieldConfig[] - values: IFormSectionData - setFieldValue: (name: string, value: IFormFieldValue) => void + values: ActionFormData + setFieldValue: (name: string, value: FieldValue | undefined) => void onClick?: () => void onChange: (e: React.ChangeEvent) => void onBlur: (e: React.FocusEvent) => void resetDependentSelectValues: (name: string) => void - value: IFormFieldValue + value: FieldValueByType[FieldType['type']] touched: boolean error: string - formData: IFormSectionData + formData: ActionFormData disabled?: boolean onUploadingStateChanged?: (isUploading: boolean) => void requiredErrorMessage?: MessageDescriptor setFieldTouched: (name: string, isTouched?: boolean) => void } -const GeneratedInputField = React.memo( - ({ +const GeneratedInputField = React.memo( + ({ fieldDefinition, onChange, onBlur, @@ -99,7 +99,7 @@ const GeneratedInputField = React.memo( requiredErrorMessage, fields, values - }) => { + }: GeneratedInputFieldProps) => { const intl = useIntl() const inputFieldProps = { @@ -133,6 +133,12 @@ const GeneratedInputField = React.memo( intl.formatMessage(fieldDefinition.placeholder) } + const handleFileChange = React.useCallback( + (value: FileFieldValue | undefined) => + setFieldValue(fieldDefinition.id, value), + [fieldDefinition.id, setFieldValue] + ) + if (fieldDefinition.type === DATE) { return ( @@ -166,17 +172,6 @@ const GeneratedInputField = React.memo( ) } - if (fieldDefinition.type === HIDDEN) { - const { error, touched, ...allowedInputProps } = inputProps - - return ( - - ) - } if (fieldDefinition.type === TEXT) { return ( @@ -190,13 +185,25 @@ const GeneratedInputField = React.memo( ) } + if (fieldDefinition.type === 'FILE') { + const value = formData[fieldDefinition.id] as FileFieldValue + return ( + + + + ) + } return
Unsupported field type {fieldDefinition.type}
} ) GeneratedInputField.displayName = 'MemoizedGeneratedInputField' -type FormData = Record +type FormData = Record const mapFieldsToValues = (fields: FieldConfig[], formData: FormData) => fields.reduce((memo, field) => { @@ -211,15 +218,15 @@ interface ExposedProps { id: string fieldsToShowValidationErrors?: FieldConfig[] setAllFieldsDirty: boolean - onChange: (values: IFormSectionData) => void - formData: Record + onChange: (values: ActionFormData) => void + formData: Record onSetTouched?: (func: ISetTouchedFunction) => void requiredErrorMessage?: MessageDescriptor onUploadingStateChanged?: (isUploading: boolean) => void initialValues?: IAdvancedSearchFormState } -type AllProps = ExposedProps & IntlShapeProps & FormikProps +type AllProps = ExposedProps & IntlShapeProps & FormikProps class FormSectionComponent extends React.Component { componentDidUpdate(prevProps: AllProps) { @@ -289,7 +296,7 @@ class FormSectionComponent extends React.Component { setFieldValuesWithDependency = ( fieldName: string, - value: IFormFieldValue + value: FieldValue | undefined ) => { const updatedValues = cloneDeep(this.props.values) set(updatedValues, fieldName, value) @@ -328,13 +335,24 @@ class FormSectionComponent extends React.Component { } render() { - const { values, fields, setFieldTouched, touched, intl, formData } = - this.props + const { + values, + fields: fieldsWithDotIds, + setFieldTouched, + touched, + intl, + formData + } = this.props const language = this.props.intl.locale const errors = this.props.errors as unknown as Errors + const fields = fieldsWithDotIds.map((field) => ({ + ...field, + id: field.id.replaceAll('.', FIELD_SEPARATOR) + })) + return (
{fields.map((field) => { @@ -348,7 +366,7 @@ class FormSectionComponent extends React.Component { const conditionalActions: string[] = getConditionalActionsForField( field, - { ...formData, ...values } + { $form: values, $now: new Date().toISOString().split('T')[0] } ) if (conditionalActions.includes('hide')) { @@ -374,7 +392,11 @@ class FormSectionComponent extends React.Component { error={isFieldDisabled ? '' : error} fields={fields} formData={formData} - touched={flatten(touched)[field.id] || false} + touched={ + makeFormikFieldIdsOpenCRVSCompatible(touched)[ + field.id + ] || false + } values={values} onUploadingStateChanged={ this.props.onUploadingStateChanged @@ -391,26 +413,50 @@ class FormSectionComponent extends React.Component { } } +/* + * Formik has a feature that automatically nests all form keys that have a dot in them. + * Because our form field ids can have dots in them, we temporarily transform those dots + * to a different character before passing the data to Formik. This function unflattens + */ +const FIELD_SEPARATOR = '____' +function makeFormFieldIdsFormikCompatible(data: Record) { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key.replaceAll('.', FIELD_SEPARATOR), + value + ]) + ) +} + +function makeFormikFieldIdsOpenCRVSCompatible(data: Record) { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [ + key.replaceAll(FIELD_SEPARATOR, '.'), + value + ]) + ) +} + export const FormFieldGenerator: React.FC = (props) => { const intl = useIntl() - const nestedFormData = unflatten(props.formData) + const nestedFormData = makeFormFieldIdsFormikCompatible(props.formData) - const onChange = (values: IFormSectionData) => { - props.onChange(flatten(values)) + const onChange = (values: ActionFormData) => { + props.onChange(makeFormikFieldIdsOpenCRVSCompatible(values)) } - const initialValues = unflatten( + const initialValues = makeFormFieldIdsFormikCompatible( props.initialValues ?? mapFieldsToValues(props.fields, nestedFormData) ) return ( - + initialValues={initialValues} validate={(values) => getValidationErrorsForForm( props.fields, - flatten(values), + makeFormikFieldIdsOpenCRVSCompatible(values), props.requiredErrorMessage ) } diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx new file mode 100644 index 00000000000..da95823844b --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentListPreview.tsx @@ -0,0 +1,96 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import * as React from 'react' +import styled from 'styled-components' +import { FieldValue, FileFieldValue } from '@opencrvs/commons/client' +import { Button } from '@opencrvs/components/lib/Button/Button' +import { Icon } from '@opencrvs/components/lib/Icon/Icon' +import { Link } from '@opencrvs/components/lib/Link/Link' +import { ISelectOption } from '@opencrvs/components/lib/Select' + +const Wrapper = styled.div` + max-width: 100%; + & > *:last-child { + margin-bottom: 8px; + border-bottom: 1.5px solid ${({ theme }) => theme.colors.grey100}; + } +` +const Container = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + border-top: 1.5px solid ${({ theme }) => theme.colors.grey100}; + height: 48px; + padding: 0px 10px; +` + +const Label = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + svg { + flex-shrink: 0; + } +` + +interface Props { + id?: string + attachment?: FileFieldValue + label?: string + onSelect: (document: FieldValue | FileFieldValue) => void + dropdownOptions?: ISelectOption[] + onDelete?: (image: FieldValue | FileFieldValue) => void +} + +export function DocumentListPreview({ + id, + attachment, + label, + onSelect, + dropdownOptions, + onDelete +}: Props) { + function getFormattedLabelForDocType(docType: string) { + const matchingOptionForDocType = + dropdownOptions && + dropdownOptions.find((option) => option.value === docType) + return matchingOptionForDocType && matchingOptionForDocType.label + } + return ( + + {attachment && label && ( + + + + + )} + + ) +} diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx new file mode 100644 index 00000000000..9025e4d47a0 --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/DocumentPreview.tsx @@ -0,0 +1,159 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import * as React from 'react' +import { useState } from 'react' +import styled from 'styled-components' +import { FileFieldValue } from '@opencrvs/commons' +import { AppBar } from '@opencrvs/components/lib/AppBar' +import { Button } from '@opencrvs/components/lib/Button' +import { DividerVertical } from '@opencrvs/components/lib/Divider' +import PanControls from '@opencrvs/components/lib/DocumentViewer/components/PanControls' +import PanViewer from '@opencrvs/components/lib/DocumentViewer/components/PanViewer' +import { Icon } from '@opencrvs/components/lib/Icon' +import { Stack } from '@opencrvs/components/lib/Stack' +import { getFullURL } from '@client/v2-events/features/files/useFileUpload' + +const ViewerWrapper = styled.div` + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 4; + width: 100%; + height: 100%; + background: ${({ theme }) => theme.colors.white}; +` + +const ViewerContainer = styled.div` + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + align-items: center; + & img { + max-height: 80vh; + max-width: 80vw; + width: auto; + } +` + +interface IProps { + previewImage: FileFieldValue + disableDelete?: boolean + title?: string + goBack: () => void + onDelete: (image: FileFieldValue) => void + id?: string +} + +export function DocumentPreview({ + previewImage, + title, + goBack, + onDelete, + disableDelete, + id +}: IProps) { + const [zoom, setZoom] = useState(1) + const [rotation, setRotation] = useState(0) + + function zoomIn() { + setZoom((prevState) => prevState + 0.2) + } + function zoomOut() { + setZoom((prevState) => (prevState >= 1 ? prevState - 0.2 : prevState)) + } + function rotateLeft() { + setRotation((prevState) => (prevState - 90) % 360) + } + + return ( + + } + desktopRight={ + + + {!disableDelete && ( + <> + + + + )} + + + + } + desktopTitle={title} + mobileLeft={} + mobileRight={ + + + {!disableDelete && ( + + )} + + + } + mobileTitle={title} + /> + + + + + + ) +} diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx new file mode 100644 index 00000000000..2dc21bf1336 --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/FileInput.tsx @@ -0,0 +1,76 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ + +import React, { ComponentProps } from 'react' +import { FileFieldValue } from '@opencrvs/commons/client' +import { useFileUpload } from '@client/v2-events/features/files/useFileUpload' +import { SimpleDocumentUploader } from './SimpleDocumentUploader' + +export function FileInput( + props: Omit< + ComponentProps, + 'onComplete' | 'label' | 'error' + > & { + value: FileFieldValue | undefined + onChange: (value?: FileFieldValue) => void + error?: boolean + } +) { + const { value, onChange, name, description, allowedDocType } = props + + const [file, setFile] = React.useState(value) + + const { uploadFiles } = useFileUpload(name, { + onSuccess: ({ filename }) => { + if (!file) { + throw new Error('File is not defined. This should never happen') + } + setFile({ + filename, + originalFilename: file.originalFilename, + type: file.type + }) + + onChange({ + filename, + originalFilename: file.originalFilename, + type: file.type + }) + } + }) + + return ( + { + if (newFile) { + setFile({ + filename: newFile.name, + originalFilename: newFile.name, + type: newFile.type + }) + uploadFiles(newFile) + } else { + setFile(undefined) + onChange(undefined) + } + }} + /> + ) +} + +export const FileOutput = null diff --git a/packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx b/packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx new file mode 100644 index 00000000000..84e9cfdd49e --- /dev/null +++ b/packages/client/src/v2-events/components/forms/inputs/FileInput/SimpleDocumentUploader.tsx @@ -0,0 +1,171 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * OpenCRVS is also distributed under the terms of the Civil Registration + * & Healthcare Disclaimer located at http://opencrvs.org/license. + * + * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. + */ +import React, { useState } from 'react' +import { + injectIntl, + WrappedComponentProps as IntlShapeProps, + MessageDescriptor +} from 'react-intl' +import styled from 'styled-components' +import { FieldValue, FileFieldValue } from '@opencrvs/commons/client' +import { ErrorText } from '@opencrvs/components/lib/ErrorText' +import { ImageUploader } from '@opencrvs/components/lib/ImageUploader' +import { buttonMessages, formMessages as messages } from '@client/i18n/messages' +import { DocumentPreview } from './DocumentPreview' +import { DocumentListPreview } from './DocumentListPreview' + +const DocumentUploader = styled(ImageUploader)` + color: ${({ theme }) => theme.colors.primary}; + background: ${({ theme }) => theme.colors.white}; + border: ${({ theme }) => `2px solid ${theme.colors.primary}`}; + border-radius: 4px; + ${({ theme }) => theme.fonts.bold14}; + height: 40px; + text-transform: initial; + + @media (max-width: ${({ theme }) => theme.grid.breakpoints.md}px) { + margin-left: 0px; + margin-top: 10px; + } +` + +const FieldDescription = styled.div` + margin-top: 0px; + margin-bottom: 6px; +` + +type IFullProps = { + name: string + label?: string + file?: FileFieldValue + description?: string + allowedDocType?: string[] + error?: string + disableDeleteInPreview?: boolean + onComplete: (file: File | null) => void + touched?: boolean + onUploadingStateChanged?: (isUploading: boolean) => void + requiredErrorMessage?: MessageDescriptor + previewTransformer?: (files: FileFieldValue) => FileFieldValue +} & IntlShapeProps + +function SimpleDocumentUploaderComponent({ + allowedDocType, + name, + onUploadingStateChanged, + intl, + previewTransformer, + onComplete, + label, + file, + description, + error: errorProps, + disableDeleteInPreview, + requiredErrorMessage, + touched +}: IFullProps) { + const [error, setError] = useState('') + const [previewImage, setPreviewImage] = useState(null) + const [filesBeingUploaded, setFilesBeingUploaded] = useState< + { label: string }[] + >([]) + + function handleFileChange(uploadedImage: File) { + setFilesBeingUploaded([ + ...filesBeingUploaded, + { label: uploadedImage.name } + ]) + + onUploadingStateChanged && onUploadingStateChanged(true) + + if ( + allowedDocType && + allowedDocType.length > 0 && + !allowedDocType.includes(uploadedImage.type) + ) { + onUploadingStateChanged && onUploadingStateChanged(false) + setFilesBeingUploaded([]) + const newErrorMessage = intl.formatMessage(messages.fileUploadError, { + type: allowedDocType + .map((docTypeStr) => docTypeStr.split('/').pop()) + .join(', ') + }) + + setError(newErrorMessage) + } else { + onUploadingStateChanged && onUploadingStateChanged(false) + onComplete(uploadedImage) + setError('') + setFilesBeingUploaded([]) + } + } + + function selectForPreview(selectedPreviewImage: FieldValue) { + if (previewTransformer) { + return setPreviewImage( + previewTransformer(selectedPreviewImage as FileFieldValue) + ) + } + setPreviewImage(selectedPreviewImage as FileFieldValue) + } + + function closePreviewSection() { + setPreviewImage(null) + } + + function onDelete(image: FieldValue) { + onComplete(null) + closePreviewSection() + } + + const errorMessage = + (requiredErrorMessage && intl.formatMessage(requiredErrorMessage)) || + error || + errorProps || + '' + + return ( + <> + {description && {description}} + {errorMessage && (touched || error) && ( + {errorMessage} + )} + + {previewImage && ( + + )} + {!file && ( + + {intl.formatMessage(messages.uploadFile)} + + )} + + ) +} + +export const SimpleDocumentUploader = injectIntl<'intl', IFullProps>( + SimpleDocumentUploaderComponent +) diff --git a/packages/client/src/v2-events/components/forms/utils.ts b/packages/client/src/v2-events/components/forms/utils.ts index f26a3c2fdf7..265ed82d572 100644 --- a/packages/client/src/v2-events/components/forms/utils.ts +++ b/packages/client/src/v2-events/components/forms/utils.ts @@ -8,16 +8,19 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { BaseField, FieldConfig, validate } from '@opencrvs/commons/client' import { - DependencyInfo, - IFormFieldValue, - IFormSectionData -} from '@client/forms' + ActionFormData, + BaseField, + ConditionalParameters, + FieldConfig, + FieldValue, + validate +} from '@opencrvs/commons/client' +import { DependencyInfo } from '@client/forms' export function handleInitialValue( field: FieldConfig, - formData: IFormSectionData + formData: ActionFormData ) { const initialValue = field.initialValue @@ -30,23 +33,16 @@ export function handleInitialValue( return initialValue } -export type FlatFormData = Record - export function getConditionalActionsForField( field: FieldConfig, - values: FlatFormData + values: ConditionalParameters ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!field.conditionals) { return [] } return field.conditionals - .filter((conditional) => - validate(conditional.conditional, { - $form: values, - $now: new Date().toISOString().split('T')[0] - }) - ) + .filter((conditional) => validate(conditional.conditional, values)) .map((conditional) => conditional.type) } @@ -55,14 +51,10 @@ export function evalExpressionInFieldDefinition( /* * These are used in the eval expression */ - { $form }: { $form: FlatFormData } + { $form }: { $form: ActionFormData } ) { // eslint-disable-next-line no-eval - return eval(expression) as IFormFieldValue -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + return eval(expression) as FieldValue } export function hasInitialValueDependencyInfo( @@ -85,47 +77,3 @@ export function getDependentFields( return field.initialValue.dependsOn.includes(fieldName) }) } - -export function flatten( - obj: Record, - parentKey = '', - separator = '.' -): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(obj)) { - const newKey = parentKey ? `${parentKey}${separator}${key}` : key - - if (isRecord(value)) { - Object.assign( - result, - flatten(value as Record, newKey, separator) - ) - } else { - result[newKey] = value - } - } - - return result -} - -export function unflatten( - obj: Record, - separator = '.' -): Record> { - const result: Record> = {} - - for (const [key, value] of Object.entries(obj)) { - const keys = key.split(separator) - let current: Record> = result - - keys.forEach((part, index) => { - if (!current[part] || typeof current[part] !== 'object') { - current[part] = index === keys.length - 1 ? value : {} - } - current = current[part] as Record> - }) - } - - return result -} diff --git a/packages/client/src/v2-events/components/forms/validation.ts b/packages/client/src/v2-events/components/forms/validation.ts index d212769bdcf..d1a7246b65c 100644 --- a/packages/client/src/v2-events/components/forms/validation.ts +++ b/packages/client/src/v2-events/components/forms/validation.ts @@ -14,8 +14,9 @@ import { FieldConfig, validate } from '@opencrvs/commons/client' +import { ActionFormData } from '@opencrvs/commons' import { IValidationResult } from '@client/utils/validate' -import { FlatFormData, getConditionalActionsForField } from './utils' +import { getConditionalActionsForField } from './utils' interface IFieldErrors { errors: IValidationResult[] @@ -45,7 +46,7 @@ function isFieldDisabled(field: FieldConfig, params: ConditionalParameters) { function getValidationErrors( field: FieldConfig, - values: FlatFormData, + values: ActionFormData, requiredErrorMessage?: MessageDescriptor, checkValidationErrorsOnly?: boolean ) { @@ -90,7 +91,7 @@ function getValidationErrors( } export function getValidationErrorsForForm( fields: FieldConfig[], - values: FlatFormData, + values: ActionFormData, requiredErrorMessage?: MessageDescriptor, checkValidationErrorsOnly?: boolean ) { diff --git a/packages/client/src/v2-events/features/debug/debug.tsx b/packages/client/src/v2-events/features/debug/debug.tsx index 8f3c9b8b80e..42954c06307 100644 --- a/packages/client/src/v2-events/features/debug/debug.tsx +++ b/packages/client/src/v2-events/features/debug/debug.tsx @@ -9,14 +9,14 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ /* stylelint-disable */ -import React from 'react' +import { useQueryClient } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import React from 'react' import styled from 'styled-components' -import { useQueryClient } from '@tanstack/react-query' import { v4 as uuid } from 'uuid' import { Text } from '@opencrvs/components' -import { useOnlineStatus } from '@client/utils' import { useEvents } from '@client/v2-events/features/events/useEvents/useEvents' +import { useOnlineStatus } from '@client/utils' /* Debug file should be the only transient component which will not be present in near future. */ /* eslint-disable react/jsx-no-literals */ @@ -106,9 +106,6 @@ export function Debug() { - - Local records - diff --git a/packages/client/src/v2-events/features/events/EventSelection.tsx b/packages/client/src/v2-events/features/events/EventSelection.tsx index 46a28b864cf..22bf74f5681 100644 --- a/packages/client/src/v2-events/features/events/EventSelection.tsx +++ b/packages/client/src/v2-events/features/events/EventSelection.tsx @@ -23,11 +23,10 @@ import { Icon } from '@opencrvs/components/lib/Icon' import { RadioButton } from '@opencrvs/components/lib/Radio' import { Stack } from '@opencrvs/components/lib/Stack' import { ROUTES } from '@client/v2-events/routes' -import { Debug } from '@client/v2-events/features/debug/debug' -import { useEvents } from './useEvents/useEvents' -import { useEventFormNavigation } from './useEventFormNavigation' -import { useEventFormData } from './useEventFormData' import { useEventConfigurations } from './useEventConfiguration' +import { useEventFormData } from './useEventFormData' +import { useEventFormNavigation } from './useEventFormNavigation' +import { useEvents } from './useEvents/useEvents' const messages = defineMessages({ registerNewEventTitle: { @@ -182,7 +181,6 @@ export function EventSelection() { - ) } diff --git a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx index 81b8bcca939..ced9721922c 100644 --- a/packages/client/src/v2-events/features/events/actions/declare/Review.tsx +++ b/packages/client/src/v2-events/features/events/actions/declare/Review.tsx @@ -192,8 +192,8 @@ export function Review() { form={form} // @todo: Update to use dynamic title title={intl.formatMessage(formConfigs[0].review.title, { - firstname: form['applicant.firstname'], - surname: form['applicant.surname'] + firstname: form['applicant.firstname'] as string, + surname: form['applicant.surname'] as string })} > JSX.Element) + > +> = { + FILE: FileOutput +} + +function DefaultOutput({ value }: { value: T }) { + return <>{value.toString() || ''} +} + /** * Review component, used to display the "read" version of the form. * User can review the data and take actions like declare, reject or edit the data. @@ -170,7 +193,7 @@ function ReviewComponent({ children: React.ReactNode eventConfig: EventConfig formConfig: FormConfig - form: Record + form: ActionFormData onEdit: ({ pageId, fieldId }: { pageId: string; fieldId?: string }) => void title: string }) { @@ -222,30 +245,50 @@ function ReviewComponent({ name={'Accordion_' + page.id} > - {page.fields.map((field) => ( - { - e.stopPropagation() + {page.fields + .filter( + (field) => + // Formatters can explicitly define themselves to be null + // this means a value display row in not rendered at all + FIELD_TYPE_FORMATTERS[field.type] !== null + ) + .map((field) => { + const Output = + FIELD_TYPE_FORMATTERS[field.type] || DefaultOutput + + const hasValue = form[field.id] !== undefined + + const valueDisplay = hasValue ? ( + + ) : ( + '' + ) + + return ( + { + e.stopPropagation() - onEdit({ - pageId: page.id, - fieldId: field.id - }) - }} - > - {intl.formatMessage( - reviewMessages.changeButton - )} - - } - id={field.id} - label={intl.formatMessage(field.label)} - value={form[field.id] || ''} - /> - ))} + onEdit({ + pageId: page.id, + fieldId: field.id + }) + }} + > + {intl.formatMessage( + reviewMessages.changeButton + )} + + } + id={field.id} + label={intl.formatMessage(field.label)} + value={valueDisplay} + /> + ) + })} diff --git a/packages/client/src/v2-events/features/events/fixtures.ts b/packages/client/src/v2-events/features/events/fixtures.ts index bd3a157ce0f..dfb84a27302 100644 --- a/packages/client/src/v2-events/features/events/fixtures.ts +++ b/packages/client/src/v2-events/features/events/fixtures.ts @@ -8,7 +8,119 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { EventConfig } from '@opencrvs/commons/client' +import { EventConfig, FormConfig } from '@opencrvs/commons/client' + +export const DEFAULT_FORM = { + 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' + } + } + ] + } + ] +} satisfies FormConfig /** @knipignore */ export const tennisClubMembershipEvent = { @@ -127,119 +239,7 @@ export const tennisClubMembershipEvent = { 'This is shown as the action name anywhere the user can trigger the action from', id: 'event.tennis-club-membership.action.declare.label' }, - forms: [ - { - 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' - } - } - ] - } - ] - } - ] + forms: [DEFAULT_FORM] } ] } satisfies EventConfig diff --git a/packages/client/src/v2-events/features/events/useEventFormData.ts b/packages/client/src/v2-events/features/events/useEventFormData.ts index 3fe857ed231..ca60088ba79 100644 --- a/packages/client/src/v2-events/features/events/useEventFormData.ts +++ b/packages/client/src/v2-events/features/events/useEventFormData.ts @@ -10,21 +10,25 @@ */ import { create } from 'zustand' -import { persist, createJSONStorage } from 'zustand/middleware' -import { ActionInput } from '@opencrvs/commons/client' +import { createJSONStorage, persist } from 'zustand/middleware' +import { ActionFormData } from '@opencrvs/commons/client' import { storage } from '@client/storage' -type FormData = ActionInput['data'] - interface EventFormData { - formValues: FormData - setFormValues: (eventId: string, data: FormData) => void - getFormValues: (eventId: string) => FormData + formValues: ActionFormData + setFormValues: (eventId: string, data: ActionFormData) => void + getFormValues: (eventId: string) => ActionFormData getTouchedFields: () => Record clear: () => void eventId: string } +function removeUndefinedKeys(data: ActionFormData) { + return Object.fromEntries( + Object.entries(data).filter(([_, value]) => value !== undefined) + ) +} + export const useEventFormData = create()( persist( (set, get) => ({ @@ -32,8 +36,10 @@ export const useEventFormData = create()( eventId: '', getFormValues: (eventId: string) => get().eventId === eventId ? get().formValues : {}, - setFormValues: (eventId: string, data: FormData) => - set(() => ({ eventId, formValues: data })), + setFormValues: (eventId: string, data: ActionFormData) => { + const formValues = removeUndefinedKeys(data) + return set(() => ({ eventId, formValues })) + }, getTouchedFields: () => Object.fromEntries( Object.entries(get().formValues).map(([key, value]) => [key, true]) diff --git a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx index 6d43c25bade..5c62d4df0b0 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.test.tsx @@ -60,7 +60,7 @@ const createHandler = trpcHandler(async ({ request }) => { createdAt: new Date('2024-12-05T18:37:31.295Z').toISOString(), createdBy: '6733309827b97e6483877188', createdAtLocation: 'ae5be1bb-6c50-4389-a72d-4c78d19ec176', - data: [] + data: {} } ] }) @@ -140,7 +140,7 @@ describe('events that have unsynced actions', () => { }) => { server.use(http.post('/api/events/event.create', errorHandler)) createEventHook.result.current.mutate({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_TRANSACTION_' }) @@ -160,7 +160,7 @@ describe('events that have unsynced actions', () => { createEventHook }) => { await createEventHook.result.current.mutateAsync({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_TRANSACTION_' }) // Wait for backend to sync @@ -171,7 +171,6 @@ describe('events that have unsynced actions', () => { }) ) - // Store still has one event await waitFor(() => { expect(eventsHook.result.current.events.data).toHaveLength(1) }) @@ -189,7 +188,7 @@ describe('events that have unsynced actions', () => { createEventHook }) => { await createEventHook.result.current.mutateAsync({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_TRANSACTION_' }) // Wait for backend to sync @@ -215,7 +214,7 @@ test('events that have unsynced actions are treated as "outbox" ', server.use(http.post('/api/events/event.create', errorHandler)) createHook.result.current.mutate({ - type: 'birth', + type: 'TENNIS_CLUB_MEMBERSHIP', transactionId: '_TEST_FAILING_TRANSACTION_' }) @@ -242,8 +241,8 @@ test('events that have unsynced actions are treated as "outbox" ', mutation.execute(mutation.state.variables) ) ) - - await waitFor(() => expect(serverSpy.mock.calls).toHaveLength(1)) + // @TODO: Check if this change was intentional or is there some inconsistency with the cache. + await waitFor(() => expect(serverSpy.mock.calls).toHaveLength(2)) await waitFor(() => { expect(eventsHook.result.current.getOutbox()).toHaveLength(0) 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 3ee4ef18b10..cdd9cfcccdd 100644 --- a/packages/client/src/v2-events/features/events/useEvents/useEvents.ts +++ b/packages/client/src/v2-events/features/events/useEvents/useEvents.ts @@ -9,16 +9,21 @@ * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { hashKey, QueryObserver, useSuspenseQuery } from '@tanstack/react-query' +import { + hashKey, + MutationKey, + QueryObserver, + useSuspenseQuery +} from '@tanstack/react-query' import { getQueryKey } from '@trpc/react-query' import { EventDocument, CreatedAction } from '@opencrvs/commons/client' import { api, queryClient, utils } from '@client/v2-events/trpc' import { storage } from '@client/storage' -// @todo -// export function preloadData() { -// utils.config.get.ensureData() -// } +/* + * Local event storage + */ +const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] function getCanonicalEventId( events: EventDocument[], @@ -49,7 +54,14 @@ function wrapMutationFnEventIdResolution( canonicalMutationFn: (params: T) => Promise ): (params: T) => Promise { return async (params: T) => { - const events = await readEventsFromStorage() + const events = queryClient.getQueryData( + EVENTS_PERSISTENT_STORE_STORAGE_KEY + ) + + if (!events) { + return canonicalMutationFn(params) + } + const id = getCanonicalEventId(events, params.eventId) if (!id) { return canonicalMutationFn(params) @@ -63,175 +75,28 @@ function wrapMutationFnEventIdResolution( } } -const EVENTS_PERSISTENT_STORE_STORAGE_KEY = ['persisted-events'] - -queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { - queryFn: async () => readEventsFromStorage() -}) - utils.event.actions.declare.setMutationDefaults(({ canonicalMutationFn }) => ({ - // This retry ensures on page reload if event have not yet synced, - // the action will be retried once - retry: 1, - retryDelay: 5000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn), - onMutate: async (actionInput) => { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventToUpdate = events.find( - // This hook is executed before mutationFn, so we need to check for both ids - (e) => [e.id, e.transactionId].includes(actionInput.eventId) - ) - - const eventsWithoutUpdated = events.filter( - (e) => e.id !== actionInput.eventId - ) - await writeEventsToStorage( - eventToUpdate - ? [...eventsWithoutUpdated, eventToUpdate] - : eventsWithoutUpdated - ) - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - return { events } - }, - onSettled: async (response) => { - /* - * Updates event in store - */ - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter((e) => e.id !== response.id) - - await writeEventsToStorage([...eventsWithoutNew, response]) - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.events.get) - }) - } + retry: true, + retryDelay: 10000, + mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) })) utils.event.actions.draft.setMutationDefaults(({ canonicalMutationFn }) => ({ - // This retry ensures on page reload if event have not yet synced, - // the action will be retried once - retry: 1, - retryDelay: 5000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn), - onMutate: async (actionInput) => { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventToUpdate = events.find( - // This hook is executed before mutationFn, so we need to check for both ids - (e) => [e.id, e.transactionId].includes(actionInput.eventId) - ) - - const eventsWithoutUpdated = events.filter( - (e) => e.id !== actionInput.eventId - ) - await writeEventsToStorage( - eventToUpdate - ? [...eventsWithoutUpdated, eventToUpdate] - : eventsWithoutUpdated - ) - - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - return { events } - }, - onSettled: async (response) => { - /* - * Updates event in store - */ - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter((e) => e.id !== response.id) - - await writeEventsToStorage([...eventsWithoutNew, response]) - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.events.get) - }) - } + retry: true, + retryDelay: 10000, + mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) })) -utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ - // This retry ensures on page reload if event have not yet synced, - // the action will be retried once - retry: 1, - retryDelay: 5000, - mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn), - onMutate: async (actionInput) => { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventToUpdate = events.find( - // This hook is executed before mutationFn, so we need to check for both ids - (e) => [e.id, e.transactionId].includes(actionInput.eventId) - ) - const eventsWithoutUpdated = events.filter( - (e) => e.id !== actionInput.eventId - ) - await writeEventsToStorage( - eventToUpdate - ? [...eventsWithoutUpdated, eventToUpdate] - : eventsWithoutUpdated - ) - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - return { events } - }, - onSettled: async (response) => { - /* - * Updates event in store - */ - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter((e) => e.id !== response.id) - - await writeEventsToStorage([...eventsWithoutNew, response]) - return queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.events.get) - }) - } +utils.event.actions.register.setMutationDefaults(({ canonicalMutationFn }) => ({ + retry: true, + retryDelay: 10000, + mutationFn: wrapMutationFnEventIdResolution(canonicalMutationFn) })) utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ mutationFn: canonicalMutationFn, - retry: 0, - onMutate: async (newEvent) => { + retry: true, + onMutate: (newEvent) => { const optimisticEvent = { id: newEvent.transactionId, type: newEvent.type, @@ -253,50 +118,74 @@ utils.event.create.setMutationDefaults(({ canonicalMutationFn }) => ({ // that the event is created when changing view for instance queryClient.setQueryData( EVENTS_PERSISTENT_STORE_STORAGE_KEY, - (old: EventDocument[]) => { - return [...old, optimisticEvent] + (events: EventDocument[]) => { + return [...events, optimisticEvent] } ) - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - - const events = await readEventsFromStorage() - - await writeEventsToStorage([...events, optimisticEvent]) return optimisticEvent }, - onSettled: async (response) => { - if (response) { - await queryClient.cancelQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) - const events = await readEventsFromStorage() - const eventsWithoutNew = events.filter( - (e) => e.transactionId !== response.transactionId - ) + onSuccess: async (response) => { + const events = queryClient.getQueryData( + EVENTS_PERSISTENT_STORE_STORAGE_KEY + ) - await writeEventsToStorage([...eventsWithoutNew, response]) - await queryClient.invalidateQueries({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY - }) + if (!events) { + return + } - await queryClient.invalidateQueries({ - queryKey: getQueryKey(api.event.get, response.transactionId, 'query') + queryClient + .getMutationCache() + .getAll() + .forEach((mutation) => { + /* + * Update ongoing mutations with the new event ID so that transaction id is not used for querying + */ + const hashQueryKey = ( + key: MutationKey | ReturnType | undefined + ) => key?.flat().join('.') + + if ( + hashQueryKey(mutation.options.mutationKey) === + hashQueryKey(getQueryKey(api.event.actions.declare)) + ) { + const variables = mutation.state.variables as Exclude< + ReturnType< + typeof api.event.actions.declare.useMutation + >['variables'], + undefined + > + + if (variables.eventId === response.transactionId) { + variables.eventId = response.id + } + } }) - } - }, - onSuccess: (data) => { + await queryClient.invalidateQueries({ + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY + }) + queryClient.setQueryData( EVENTS_PERSISTENT_STORE_STORAGE_KEY, - (old: EventDocument[]) => - old.filter((e) => e.transactionId !== data.transactionId).concat(data) + (state: EventDocument[]) => { + return [ + ...state.filter((e) => e.transactionId !== response.transactionId), + response + ] + } ) + + await queryClient.cancelQueries({ + queryKey: getQueryKey(api.event.get, response.transactionId, 'query') + }) } })) +queryClient.setQueryDefaults(EVENTS_PERSISTENT_STORE_STORAGE_KEY, { + queryFn: readEventsFromStorage +}) + const observer = new QueryObserver(queryClient, { queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY }) @@ -313,6 +202,12 @@ observer.subscribe((observerEvent) => { event ) }) + /* + * Persist events to browser storage + */ + if (observerEvent.data) { + void writeEventsToStorage(observerEvent.data) + } }) async function readEventsFromStorage() { @@ -386,19 +281,20 @@ export function useEvents() { } function getDrafts() { - return events.data.filter( + return storedEvents.data.filter( (event) => !event.actions.some((a) => a.type === 'DECLARE') ) } function getOutbox() { const eventFromCreateMutations = filterOutboxEventsWithMutation( - events.data, + storedEvents.data, api.event.create, (event, parameters) => event.transactionId === parameters.transactionId ) + const eventFromDeclareActions = filterOutboxEventsWithMutation( - events.data, + storedEvents.data, api.event.actions.declare, (event, parameters) => { return ( @@ -409,7 +305,7 @@ export function useEvents() { ) const eventFromRegisterActions = filterOutboxEventsWithMutation( - events.data, + storedEvents.data, api.event.actions.register, (event, parameters) => { return ( @@ -460,14 +356,13 @@ export function useEvents() { }) } - const events = useSuspenseQuery({ - queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY, - queryFn: async () => readEventsFromStorage() + const storedEvents = useSuspenseQuery({ + queryKey: EVENTS_PERSISTENT_STORE_STORAGE_KEY }) return { createEvent, - events, + events: storedEvents, getEvent, getEventById: api.event.get, getEvents: api.events.get, diff --git a/packages/client/src/v2-events/features/files/useFileUpload.ts b/packages/client/src/v2-events/features/files/useFileUpload.ts new file mode 100644 index 00000000000..e40bd9737d3 --- /dev/null +++ b/packages/client/src/v2-events/features/files/useFileUpload.ts @@ -0,0 +1,135 @@ +/* + * 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 { useMutation } from '@tanstack/react-query' +import { v4 as uuid } from 'uuid' +import { queryClient } from '@client/v2-events/trpc' +import { getToken } from '@client/utils/authUtils' + +async function uploadFile({ + file, + transactionId +}: { + file: File + transactionId: string +}): Promise<{ url: string }> { + const formData = new FormData() + formData.append('file', file) + formData.append('transactionId', transactionId) + + const response = await fetch('/api/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${getToken()}` + }, + body: formData + }) + + if (!response.ok) { + throw new Error('File upload failed') + } + + return response +} + +const MUTATION_KEY = 'uploadFile' + +/* Must match the one defined src-sw.ts */ +const CACHE_NAME = 'workbox-runtime' + +function withPostfix(str: string, postfix: string) { + if (str.endsWith(postfix)) { + return str + } + + return str + postfix +} + +export function getFullURL(filename: string) { + const minioURL = window.config.MINIO_URL + if (minioURL && typeof minioURL === 'string') { + return new URL(filename, withPostfix(minioURL, '/')).toString() + } + + throw new Error('MINIO_URL is not defined') +} + +async function cacheFile(filename: string, file: File) { + const temporaryBlob = new Blob([file], { type: file.type }) + const cacheKeys = await caches.keys() + + const cacheKey = cacheKeys.find((key) => key.startsWith(CACHE_NAME)) + + if (!cacheKey) { + // eslint-disable-next-line no-console + console.error( + `Cache ${CACHE_NAME} not found. Is service worker running properly?` + ) + return + } + + const cache = await caches.open(cacheKey) + return cache.put( + getFullURL(filename), + new Response(temporaryBlob, { headers: { 'Content-Type': file.type } }) + ) +} + +async function removeCached(filename: string) { + const cacheKeys = await caches.keys() + const cacheKey = cacheKeys.find((key) => key.startsWith(CACHE_NAME)) + + if (!cacheKey) { + // eslint-disable-next-line no-console + console.error( + `Cache ${CACHE_NAME} not found. Is service worker running properly?` + ) + return + } + + const cache = await caches.open(cacheKey) + return cache.delete(getFullURL(filename)) +} + +queryClient.setMutationDefaults([MUTATION_KEY], { + retry: true, + retryDelay: 5000, + mutationFn: uploadFile +}) + +interface Options { + onSuccess?: (data: { filename: string }) => void +} + +export function useFileUpload(fieldId: string, options: Options = {}) { + const mutation = useMutation({ + mutationFn: uploadFile, + mutationKey: [MUTATION_KEY, fieldId], + onMutate: async ({ file, transactionId }) => { + const extension = file.name.split('.').pop() + const temporaryUrl = `${transactionId}.${extension}` + + await cacheFile(temporaryUrl, file) + + options.onSuccess?.({ filename: temporaryUrl }) + }, + onSuccess: (data) => { + void removeCached(data.url) + } + }) + + return { + getFullURL, + uploadFiles: (file: File) => { + return mutation.mutate({ file, transactionId: uuid() }) + } + } +} diff --git a/packages/client/src/v2-events/layouts/workqueues/index.tsx b/packages/client/src/v2-events/layouts/workqueues/index.tsx index a070f465769..bc822792d60 100644 --- a/packages/client/src/v2-events/layouts/workqueues/index.tsx +++ b/packages/client/src/v2-events/layouts/workqueues/index.tsx @@ -11,8 +11,8 @@ import React from 'react' -import { useNavigate } from 'react-router-dom' import { noop } from 'lodash' +import { useNavigate } from 'react-router-dom' import { AppBar, Button, @@ -23,7 +23,6 @@ import { } from '@opencrvs/components' import { Plus } from '@opencrvs/components/src/icons' import { ROUTES } from '@client/v2-events/routes' -import { Debug } from '@client/v2-events/features/debug/debug' /** * Basic frame for the workqueues. Includes the left navigation and the app bar. @@ -65,7 +64,6 @@ export function WorkqueueLayout({ children }: { children: React.ReactNode }) { skipToContentText="skip" > {children} - ) } diff --git a/packages/client/src/v2-events/routes/config.tsx b/packages/client/src/v2-events/routes/config.tsx index 096c73edc65..95b71b7b442 100644 --- a/packages/client/src/v2-events/routes/config.tsx +++ b/packages/client/src/v2-events/routes/config.tsx @@ -15,6 +15,7 @@ import { EventSelection } from '@client/v2-events/features/events/EventSelection import { EventOverviewIndex } from '@client/v2-events/features/workqueues/EventOverview/EventOverview' import { WorkqueueIndex } from '@client/v2-events/features/workqueues/Workqueue' import { TRPCProvider } from '@client/v2-events/trpc' +import { Debug } from '@client/v2-events/features/debug/debug' import * as Declare from '@client/v2-events/features/events/actions/declare' import * as Register from '@client/v2-events/features/events/actions/register' import { WorkqueueLayout, FormLayout } from '@client/v2-events/layouts' @@ -30,6 +31,7 @@ export const routesConfig = { element: ( + ), children: [ diff --git a/packages/client/src/v2-events/trpc.tsx b/packages/client/src/v2-events/trpc.tsx index a176410eefa..89a8a97e05b 100644 --- a/packages/client/src/v2-events/trpc.tsx +++ b/packages/client/src/v2-events/trpc.tsx @@ -63,11 +63,10 @@ function getQueryClient() { function createIDBPersister(idbValidKey = 'reactQuery') { return { persistClient: async (client: PersistedClient) => { - await storage.setItem(idbValidKey, JSON.stringify(client)) + await storage.setItem(idbValidKey, client) }, restoreClient: async () => { const client = await storage.getItem(idbValidKey) - return client || undefined }, removeClient: async () => { @@ -89,7 +88,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { client={queryClient} persistOptions={{ persister, - maxAge: 1000 * 60 * 60 * 4, + maxAge: undefined, buster: 'persisted-indexed-db', dehydrateOptions: { shouldDehydrateMutation: (mut) => { diff --git a/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx b/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx index 8fc1553f052..dd4d884e25c 100644 --- a/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx +++ b/packages/client/src/views/RegisterForm/DeclarationForm.test.tsx @@ -92,6 +92,7 @@ describe('when user starts a new declaration', () => { let router: ReturnType beforeEach(async () => { + await flushPromises() const testApp = await createTestApp() app = testApp.app store = testApp.store @@ -383,7 +384,9 @@ describe('when user starts a new declaration', () => { await flushPromises() await goToDocumentsSection(app) }) - it('image upload field is rendered', () => { + it('image upload field is rendered', async () => { + await flushPromises() + app.update() expect(app.find('#upload_document').hostNodes()).toHaveLength(5) }) }) diff --git a/packages/client/typings/window.d.ts b/packages/client/typings/window.d.ts index fa123a9b073..3e65a7cb659 100644 --- a/packages/client/typings/window.d.ts +++ b/packages/client/typings/window.d.ts @@ -48,6 +48,7 @@ interface Window { LANGUAGES: string LOGIN_URL: string AUTH_URL: string + MINIO_URL: string MINIO_BUCKET: string COUNTRY_CONFIG_URL: string SHOW_FARAJALAND_IN_COUNTRY_LISTS: boolean diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 8eb82936cd6..661b7bc3ac0 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -57,7 +57,7 @@ export default defineConfig(({ mode }) => { srcDir: 'src/', filename: 'src-sw.ts', devOptions: { - enabled: false, + enabled: true, type: 'module', navigateFallback: 'index.html' } diff --git a/packages/commons/src/conditionals/conditionals.ts b/packages/commons/src/conditionals/conditionals.ts index 98e7f40358e..f29c06ec06e 100644 --- a/packages/commons/src/conditionals/conditionals.ts +++ b/packages/commons/src/conditionals/conditionals.ts @@ -11,7 +11,7 @@ import { JSONSchemaType } from 'ajv' import { z } from 'zod' -import { EventDocument } from '../events' +import { ActionFormData, EventDocument } from '../events' export function Conditional() { return z.any() as z.ZodType @@ -23,10 +23,10 @@ export type ConditionalParameters = { $now: string } & ( } | { $event: EventDocument - $form: Record + $form: ActionFormData } | { - $form: Record + $form: ActionFormData } ) diff --git a/packages/commons/src/events/ActionDocument.ts b/packages/commons/src/events/ActionDocument.ts index 576f5ae17f8..7a2d2ba552f 100644 --- a/packages/commons/src/events/ActionDocument.ts +++ b/packages/commons/src/events/ActionDocument.ts @@ -10,12 +10,13 @@ */ import { ActionType } from './ActionConfig' import { z } from 'zod' +import { FieldValue } from './FieldValue' const ActionBase = z.object({ createdAt: z.string().datetime(), createdBy: z.string(), - createdAtLocation: z.string(), - data: z.record(z.string(), z.any()) + data: z.record(z.string(), FieldValue), + createdAtLocation: z.string() }) const AssignedAction = ActionBase.merge( @@ -91,3 +92,5 @@ export const ActionDocument = z.discriminatedUnion('type', [ export type ActionDocument = z.infer export type CreatedAction = z.infer + +export type ActionFormData = ActionDocument['data'] diff --git a/packages/commons/src/events/ActionInput.ts b/packages/commons/src/events/ActionInput.ts index 34a2eafdcf3..c88652a21d5 100644 --- a/packages/commons/src/events/ActionInput.ts +++ b/packages/commons/src/events/ActionInput.ts @@ -11,11 +11,12 @@ import { ActionType } from './ActionConfig' import { z } from 'zod' +import { FieldValue } from './FieldValue' const BaseActionInput = z.object({ eventId: z.string(), transactionId: z.string(), - data: z.record(z.any()) + data: z.record(z.string(), FieldValue) }) const CreateActionInput = BaseActionInput.merge( diff --git a/packages/commons/src/events/FieldConfig.ts b/packages/commons/src/events/FieldConfig.ts index e6d1881dbf6..c0291cde52d 100644 --- a/packages/commons/src/events/FieldConfig.ts +++ b/packages/commons/src/events/FieldConfig.ts @@ -11,6 +11,12 @@ import { z } from 'zod' import { TranslationConfig } from './TranslationConfig' import { Conditional } from '../conditionals/conditionals' +import { + DateFieldValue, + FileFieldValue, + ParagraphFieldValue, + TextFieldValue +} from './FieldValue' export const ConditionalTypes = { SHOW: 'SHOW', @@ -51,6 +57,7 @@ const BaseField = z.object({ .optional(), required: z.boolean().default(false).optional(), disabled: z.boolean().default(false).optional(), + hidden: z.boolean().default(false).optional(), placeholder: TranslationConfig.optional(), validation: z .array( @@ -79,6 +86,14 @@ export const FieldType = { export const fieldTypes = Object.values(FieldType) export type FieldType = (typeof fieldTypes)[number] +export type FieldValueByType = { + [FieldType.TEXT]: TextFieldValue + [FieldType.DATE]: DateFieldValue + [FieldType.PARAGRAPH]: ParagraphFieldValue + [FieldType.RADIO_GROUP]: string + [FieldType.FILE]: FileFieldValue +} + const TextField = BaseField.extend({ type: z.literal(FieldType.TEXT), options: z @@ -113,10 +128,6 @@ const File = BaseField.extend({ type: z.literal(FieldType.FILE) }).describe('File upload') -const Hidden = BaseField.extend({ - type: z.literal(FieldType.HIDDEN) -}).describe('Hidden field') - const RadioGroup = BaseField.extend({ type: z.literal(FieldType.RADIO_GROUP), options: z.array( @@ -132,7 +143,6 @@ export const FieldConfig = z.discriminatedUnion('type', [ DateField, Paragraph, RadioGroup, - Hidden, File ]) diff --git a/packages/commons/src/events/FieldValue.ts b/packages/commons/src/events/FieldValue.ts new file mode 100644 index 00000000000..9f3903a3d48 --- /dev/null +++ b/packages/commons/src/events/FieldValue.ts @@ -0,0 +1,40 @@ +/* + * 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 { z } from 'zod' + +const TextFieldValue = z.string() +export type TextFieldValue = z.infer + +const DateFieldValue = z.string() +export type DateFieldValue = z.infer + +const ParagraphFieldValue = z.string() +export type ParagraphFieldValue = z.infer + +export const FileFieldValue = z.object({ + filename: z.string(), + originalFilename: z.string(), + type: z.string() +}) + +export type FileFieldValue = z.infer + +const RadioGroupFieldValue = z.string() + +export const FieldValue = z.union([ + TextFieldValue, + DateFieldValue, + ParagraphFieldValue, + FileFieldValue, + RadioGroupFieldValue +]) + +export type FieldValue = z.infer diff --git a/packages/commons/src/events/index.ts b/packages/commons/src/events/index.ts index fffde9819ae..ae6d21b7f26 100644 --- a/packages/commons/src/events/index.ts +++ b/packages/commons/src/events/index.ts @@ -21,6 +21,7 @@ export * from './ActionInput' export * from './ActionDocument' export * from './EventIndex' export * from './TranslationConfig' +export * from './FieldValue' export * from './state' export * from './utils' export * from './defineConfig' diff --git a/packages/documents/package.json b/packages/documents/package.json index bd1e6ccfa60..21ce45f54df 100644 --- a/packages/documents/package.json +++ b/packages/documents/package.json @@ -16,6 +16,7 @@ "build:clean": "rm -rf build" }, "dependencies": { + "zod": "^3.23.8", "@hapi/boom": "^9.1.1", "@hapi/hapi": "^20.0.1", "@opencrvs/commons": "^1.7.0", diff --git a/packages/documents/src/config/routes.ts b/packages/documents/src/config/routes.ts index e7e27b8ac69..e278d174b97 100644 --- a/packages/documents/src/config/routes.ts +++ b/packages/documents/src/config/routes.ts @@ -8,9 +8,16 @@ * * Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS. */ -import { documentUploadHandler } from '@documents/features/uploadDocument/handler' +import { + fileUploadHandler, + documentUploadHandler, + fileExistsHandler +} from '@documents/features/uploadDocument/handler' import { vsExportUploaderHandler } from '@documents/features/uploadVSExportFile/handler' -import { createPreSignedUrl } from '@documents/features/getDocument/handler' +import { + createPreSignedUrl, + createPresignedUrlsInBulk +} from '@documents/features/getDocument/handler' import { svgUploadHandler } from '@documents/features/uploadSvg/handler' import { MINIO_BUCKET } from '@documents/minio/constants' @@ -25,6 +32,15 @@ export const getRoutes = () => { tags: ['api'] } }, + // get presigned URLs in bulk + { + method: 'POST', + path: `/presigned-urls`, + handler: createPresignedUrlsInBulk, + config: { + tags: ['api'] + } + }, { method: 'POST', path: '/presigned-url', @@ -33,6 +49,29 @@ export const getRoutes = () => { tags: ['api'] } }, + // check if file exists + { + method: 'GET', + path: '/files/{filename}', + handler: fileExistsHandler, + config: { + tags: ['api'] + } + }, + // upload a file to minio + { + method: 'POST', + path: '/files', + handler: fileUploadHandler, + config: { + tags: ['api'], + payload: { + allow: ['multipart/form-data'], + multipart: true, + output: 'stream' + } + } + }, // upload a document { method: 'POST', diff --git a/packages/documents/src/features/getDocument/handler.ts b/packages/documents/src/features/getDocument/handler.ts index efcb47c3c9d..bf9ad46c376 100644 --- a/packages/documents/src/features/getDocument/handler.ts +++ b/packages/documents/src/features/getDocument/handler.ts @@ -12,6 +12,7 @@ import { MINIO_BUCKET } from '@documents/minio/constants' import { signFileUrl } from '@documents/minio/sign' import * as Hapi from '@hapi/hapi' +import { z } from 'zod' export function createPreSignedUrl( request: Hapi.Request, @@ -31,3 +32,24 @@ export function createPreSignedUrl( return h.response(error).code(400) } } + +const BulkPayload = z.object({ + filenames: z.array(z.string()) +}) + +export function createPresignedUrlsInBulk( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const payload = BulkPayload.safeParse(request.payload) + + if (!payload.success) { + return h.response(payload.error).code(400) + } + + const response = payload.data.filenames.map((filename) => + signFileUrl(`/${MINIO_BUCKET}/${filename}`) + ) + + return h.response(response).code(200) +} diff --git a/packages/documents/src/features/uploadDocument/handler.ts b/packages/documents/src/features/uploadDocument/handler.ts index fc860f6021f..c9f6c3f8a8f 100644 --- a/packages/documents/src/features/uploadDocument/handler.ts +++ b/packages/documents/src/features/uploadDocument/handler.ts @@ -13,7 +13,10 @@ import { MINIO_BUCKET } from '@documents/minio/constants' import * as Hapi from '@hapi/hapi' import { v4 as uuid } from 'uuid' import { fromBuffer } from 'file-type' - +import { z } from 'zod' +import { Readable } from 'stream' +import { badRequest, notFound } from '@hapi/boom' +import { logger } from '@opencrvs/commons' export interface IDocumentPayload { fileData: string metaData?: Record @@ -24,6 +27,61 @@ export type IFileInfo = { mime: string } +const HapiSchema = z.object({ + filename: z.string().min(1, 'Filename is required'), + headers: z.record(z.string()), + bytes: z.number().optional() +}) + +const FileSchema = z + .custom }>((val) => { + return '_readableState' in val && 'hapi' in val + }, 'Not a readable stream or missing hapi field') + .refine( + (val) => HapiSchema.safeParse(val.hapi).success, + 'hapi does not match the required structure' + ) + +const Payload = z.object({ + file: FileSchema, + transactionId: z.string() +}) + +export async function fileUploadHandler( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const payload = await Payload.parseAsync(request.payload).catch((error) => { + logger.error(error) + throw badRequest('Invalid payload') + }) + + const { file, transactionId } = payload + + const extension = file.hapi.filename.split('.').pop() + const filename = `${transactionId}.${extension}` + try { + await minioClient.putObject(MINIO_BUCKET, filename, file) + } catch (error) { + logger.error(error) + throw error + } + + return filename +} + +export async function fileExistsHandler( + request: Hapi.Request, + h: Hapi.ResponseToolkit +) { + const { filename } = request.params + const exists = await minioClient.statObject(MINIO_BUCKET, filename) + if (!exists) { + return notFound('File not found') + } + return h.response().code(200) +} + export async function documentUploadHandler( request: Hapi.Request, h: Hapi.ResponseToolkit diff --git a/packages/events/package.json b/packages/events/package.json index c5fc3845e0e..41f9cf8333e 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -37,6 +37,7 @@ "eslint-plugin-prettier": "^4.0.0", "lint-staged": "^15.0.0", "mongodb-memory-server": "^10.1.2", + "msw": "^2.7.0", "nodemon": "^3.0.0", "prettier": "2.8.8", "testcontainers": "^10.15.0", diff --git a/packages/events/src/environment.ts b/packages/events/src/environment.ts index f1798e1b379..2e0be2011b1 100644 --- a/packages/events/src/environment.ts +++ b/packages/events/src/environment.ts @@ -14,5 +14,6 @@ import { cleanEnv, url } from 'envalid' export const env = cleanEnv(process.env, { MONGO_URL: url({ devDefault: 'mongodb://localhost/events' }), ES_HOST: url({ devDefault: 'http://localhost:9200' }), - COUNTRY_CONFIG_URL: url({ devDefault: 'http://localhost:3040' }) + COUNTRY_CONFIG_URL: url({ devDefault: 'http://localhost:3040' }), + DOCUMENTS_URL: url({ devDefault: 'http://localhost:9050' }) }) diff --git a/packages/events/src/router/event.actions.test.ts b/packages/events/src/router/event.actions.test.ts index fdb7d380f6a..55115933cff 100644 --- a/packages/events/src/router/event.actions.test.ts +++ b/packages/events/src/router/event.actions.test.ts @@ -32,7 +32,7 @@ test('actions can be added to created events', async () => { test('Action data can be retrieved', async () => { const originalEvent = await client.event.create(generator.event.create()) - const data = { name: 'John Doe', age: 42 } + const data = { name: 'John Doe', favouriteFruit: 'Banana' } await client.event.actions.declare( generator.event.actions.declare(originalEvent.id, { data diff --git a/packages/events/src/router/events.get.test.ts b/packages/events/src/router/events.get.test.ts index 0d5c04046de..ede4831789e 100644 --- a/packages/events/src/router/events.get.test.ts +++ b/packages/events/src/router/events.get.test.ts @@ -32,7 +32,7 @@ test('Returns multiple events', async () => { }) test('Returns aggregated event with updated status and values', async () => { - const initialData = { name: 'John Doe', age: 42 } + const initialData = { name: 'John Doe', favouriteFruit: 'Banana' } const event = await client.event.create(generator.event.create()) await client.event.actions.declare( generator.event.actions.declare(event.id, { @@ -46,7 +46,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', age: 43 } + const updatedData = { name: 'John Doe', favouriteFruit: 'Strawberry' } await client.event.actions.declare( generator.event.actions.declare(event.id, { data: updatedData diff --git a/packages/events/src/router/router.ts b/packages/events/src/router/router.ts index d54a5937458..e91fba9a4f4 100644 --- a/packages/events/src/router/router.ts +++ b/packages/events/src/router/router.ts @@ -21,7 +21,7 @@ import { DraftActionInput, RegisterActionInput } from '@opencrvs/commons/events' -import { getEventsConfig } from '@events/service/config/config' +import { getEventConfigurations } from '@events/service/config/config' import { addAction, createEvent, @@ -31,6 +31,7 @@ import { } from '@events/service/events' import { EventConfig, getUUID } from '@opencrvs/commons' import { getIndexedEvents } from '@events/service/indexing/indexing' +import { presignFilesInEvent } from '@events/service/files' const ContextSchema = z.object({ user: z.object({ @@ -74,12 +75,12 @@ export type AppRouter = typeof appRouter export const appRouter = router({ config: router({ get: publicProcedure.output(z.array(EventConfig)).query(async (options) => { - return getEventsConfig(options.ctx.token) + return getEventConfigurations(options.ctx.token) }) }), event: router({ create: publicProcedure.input(EventInput).mutation(async (options) => { - const config = await getEventsConfig(options.ctx.token) + const config = await getEventConfigurations(options.ctx.token) const eventIds = config.map((c) => c.id) validateEventType({ @@ -95,7 +96,7 @@ export const appRouter = router({ }) }), patch: publicProcedure.input(EventInputWithId).mutation(async (options) => { - const config = await getEventsConfig(options.ctx.token) + const config = await getEventConfigurations(options.ctx.token) const eventIds = config.map((c) => c.id) validateEventType({ @@ -105,29 +106,34 @@ export const appRouter = router({ return patchEvent(options.input) }), - get: publicProcedure.input(z.string()).query(async ({ input }) => { - return getEventById(input) + get: publicProcedure.input(z.string()).query(async ({ input, ctx }) => { + const event = await getEventById(input) + const eventWithSignedFiles = await presignFilesInEvent(event, ctx.token) + return eventWithSignedFiles }), actions: router({ notify: publicProcedure.input(NotifyActionInput).mutation((options) => { return addAction(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token }) }), draft: publicProcedure.input(DraftActionInput).mutation((options) => { return addAction(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token }) }), declare: publicProcedure.input(DeclareActionInput).mutation((options) => { return addAction(options.input, { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token }) }), register: publicProcedure @@ -144,7 +150,8 @@ export const appRouter = router({ { eventId: options.input.eventId, createdBy: options.ctx.user.id, - createdAtLocation: options.ctx.user.primaryOfficeId + createdAtLocation: options.ctx.user.primaryOfficeId, + token: options.ctx.token } ) }) diff --git a/packages/events/src/service/config/config.ts b/packages/events/src/service/config/config.ts index 2ff95c97903..5316410a7d0 100644 --- a/packages/events/src/service/config/config.ts +++ b/packages/events/src/service/config/config.ts @@ -14,7 +14,7 @@ import { EventConfig } from '@opencrvs/commons' import fetch from 'node-fetch' import { array } from 'zod' -export async function getEventsConfig(token: string) { +export async function getEventConfigurations(token: string) { const res = await fetch(new URL('/events', env.COUNTRY_CONFIG_URL), { headers: { 'Content-Type': 'application/json', diff --git a/packages/events/src/service/events.ts b/packages/events/src/service/events.ts index d3dc8672403..7e070523178 100644 --- a/packages/events/src/service/events.ts +++ b/packages/events/src/service/events.ts @@ -10,16 +10,18 @@ */ import { - EventDocument, ActionInput, - EventInput + EventDocument, + EventInput, + FileFieldValue } from '@opencrvs/commons/events' import { getClient } from '@events/storage/mongodb' import { ActionType, getUUID } from '@opencrvs/commons' import { z } from 'zod' +import { getEventConfigurations } from './config/config' +import { fileExists } from './files' import { indexEvent } from './indexing/indexing' -import * as _ from 'lodash' export const EventInputWithId = EventInput.extend({ id: z.string() @@ -50,6 +52,7 @@ export async function getEventById(id: string) { if (!event) { throw new EventNotFoundError(id) } + return event } @@ -104,11 +107,42 @@ export async function addAction( { eventId, createdBy, + token, createdAtLocation - }: { eventId: string; createdBy: string; createdAtLocation: string } + }: { + eventId: string + createdBy: string + createdAtLocation: string + token: string + } ) { const db = await getClient() const now = new Date().toISOString() + const event = await getEventById(eventId) + + const config = await getEventConfigurations(token) + + const form = config + .find((config) => config.id === event.type) + ?.actions.find((action) => action.type === input.type) + ?.forms.find((form) => form.active) + + const fieldTypes = form?.pages.flatMap((page) => page.fields) + + for (const [key, value] of Object.entries(input.data)) { + const isFile = + fieldTypes?.find((field) => field.id === key)?.type === 'FILE' + + const fileValue = FileFieldValue.safeParse(value) + + if (!isFile || !fileValue.success) { + continue + } + + if (!(await fileExists(fileValue.data!.filename, token))) { + throw new Error(`File not found: ${value}`) + } + } await db.collection('events').updateOne( { @@ -126,9 +160,9 @@ export async function addAction( } ) - const event = await getEventById(eventId) - await indexEvent(event) - return event + const updatedEvent = await getEventById(eventId) + await indexEvent(updatedEvent) + return updatedEvent } export async function patchEvent(eventInput: EventInputWithId) { diff --git a/packages/events/src/service/files/index.ts b/packages/events/src/service/files/index.ts new file mode 100644 index 00000000000..6cc589541a5 --- /dev/null +++ b/packages/events/src/service/files/index.ts @@ -0,0 +1,154 @@ +/* + * 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 { env } from '@events/environment' +import { + ActionDocument, + EventConfig, + logger, + EventDocument, + FileFieldValue +} from '@opencrvs/commons' +import fetch from 'node-fetch' +import { getEventConfigurations } from '@events/service/config/config' + +function getFieldDefinitionForActionDataField( + configuration: EventConfig, + actionType: ActionDocument['type'], + fieldId: string +) { + const actionConfiguration = configuration.actions.find( + (action) => action.type === actionType + ) + + if (!actionConfiguration) { + return + } + + const formConfiguration = actionConfiguration.forms.find( + (form) => form.active + ) + + if (!formConfiguration) { + logger.error('Failed to find active form configuration', { + actionType + }) + throw new Error('Failed to find active form configuration') + } + + return formConfiguration.pages + .flatMap((page) => page.fields) + .find((field) => field.id === fieldId) +} + +function getFileNameAndSignature(url: string) { + const { pathname, search } = new URL(url) + const filename = pathname.split('/').pop() + return filename + search +} + +export async function presignFilesInEvent(event: EventDocument, token: string) { + const configurations = await getEventConfigurations(token) + const configuration = configurations.find( + (config) => config.id === event.type + ) + + if (!configuration) { + logger.error('Failed to find configuration for event', { + event: event.type + }) + throw new Error('Failed to find configuration for event') + } + + const actionFileFields = event.actions.flatMap((action) => + Object.entries(action.data) + .filter( + ([fieldId]) => + getFieldDefinitionForActionDataField( + configuration, + action.type, + fieldId + )?.type === 'FILE' + ) + .map<[string, string, FileFieldValue]>(([fieldId, value]) => { + return [action.type, fieldId, value as FileFieldValue] + }) + ) + + const urls = ( + await presignFiles( + actionFileFields.map(([_, __, file]) => file.filename), + token + ) + ).map(getFileNameAndSignature) + + const actions = event.actions.map((action) => { + return { + ...action, + data: Object.fromEntries( + Object.entries(action.data).map(([fieldId, value]) => { + const fileIndex = actionFileFields.findIndex( + ([actionType, fileFieldsFieldId]) => + actionType === action.type && fieldId === fileFieldsFieldId + ) + + if (fileIndex === -1) { + return [fieldId, value] + } + return [ + fieldId, + { ...(value as FileFieldValue), filename: urls[fileIndex] } + ] + }) + ) + } + }) + + return { + ...event, + actions + } +} + +export async function fileExists(filename: string, token: string) { + const res = await fetch(new URL(`/files/${filename}`, env.DOCUMENTS_URL), { + method: 'HEAD', + headers: { + Authorization: `Bearer ${token}` + } + }) + + return res.ok +} + +async function presignFiles( + filenames: string[], + token: string +): Promise { + const res = await fetch(new URL(`/presigned-urls`, env.DOCUMENTS_URL), { + method: 'POST', + body: JSON.stringify({ filenames }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + if (!res.ok) { + logger.error('Failed to presign files', { + filenames, + status: res.status, + text: await res.text() + }) + throw new Error('Failed to presign files') + } + + return res.json() +} diff --git a/packages/events/src/tests/msw.ts b/packages/events/src/tests/msw.ts new file mode 100644 index 00000000000..c17c34de0e3 --- /dev/null +++ b/packages/events/src/tests/msw.ts @@ -0,0 +1,25 @@ +/* + * 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 { http, HttpResponse, PathParams } from 'msw' +import { env } from '@events/environment' +import { setupServer } from 'msw/node' + +const handlers = [ + http.post, { filenames: string[] }>( + `${env.DOCUMENTS_URL}/presigned-urls`, + async (info) => { + const request = await info.request.json() + return HttpResponse.json(request.filenames) + } + ) +] + +export const mswServer = setupServer(...handlers) diff --git a/packages/events/src/tests/setup.ts b/packages/events/src/tests/setup.ts index b2be95905c1..48df789b96c 100644 --- a/packages/events/src/tests/setup.ts +++ b/packages/events/src/tests/setup.ts @@ -16,11 +16,12 @@ import { resetServer as resetESServer, setupServer as setupESServer } from '@events/storage/__mocks__/elasticsearch' +import { mswServer } from './msw' vi.mock('@events/storage/mongodb') vi.mock('@events/storage/elasticsearch') vi.mock('@events/service/config/config', () => ({ - getEventsConfig: () => + getEventConfigurations: () => Promise.all([ tennisClubMembershipEvent, { ...tennisClubMembershipEvent, id: 'TENNIS_CLUB_MEMBERSHIP_PREMIUM' } @@ -29,3 +30,21 @@ vi.mock('@events/service/config/config', () => ({ beforeAll(() => Promise.all([setupESServer()]), 100000) afterEach(() => Promise.all([resetMongoServer(), resetESServer()])) + +beforeAll(() => + mswServer.listen({ + onUnhandledRequest: (req) => { + const elasticRegex = /http:\/\/localhost:551\d{2}\/.*/ + + const isElasticResetCall = + req.method === 'DELETE' && elasticRegex.test(req.url) + + if (!isElasticResetCall) { + // eslint-disable-next-line no-console + console.warn(`Unmocked request: ${req.method} ${req.url}`) + } + } + }) +) +afterEach(() => mswServer.resetHandlers()) +afterAll(() => mswServer.close()) diff --git a/packages/gateway/src/config/routes.ts b/packages/gateway/src/config/routes.ts index b592f0b57fa..59d8e74762e 100644 --- a/packages/gateway/src/config/routes.ts +++ b/packages/gateway/src/config/routes.ts @@ -21,6 +21,7 @@ import sendVerifyCodeHandler, { responseSchema } from '@gateway/routes/verifyCode/handler' import { trpcProxy } from '@gateway/v2-events/event-config/routes' +import { DOCUMENTS_URL } from '@gateway/constants' export const getRoutes = () => { const routes: ServerRoute[] = [ @@ -77,6 +78,27 @@ export const getRoutes = () => { } } }, + { + method: 'POST', + path: '/upload', + handler: async (req, h) => { + if (process.env.NODE_ENV !== 'production') { + await new Promise((resolve) => + setTimeout(resolve, Math.random() * 30000) + ) + } + return h.proxy({ + uri: `${DOCUMENTS_URL}/files`, + passThrough: true + }) + }, + options: { + payload: { + output: 'data', + parse: false + } + } + }, catchAllProxy.locations, catchAllProxy.locationsSuffix, diff --git a/packages/gateway/src/graphql/schema.d.ts b/packages/gateway/src/graphql/schema.d.ts index 18acb49229b..af996141149 100644 --- a/packages/gateway/src/graphql/schema.d.ts +++ b/packages/gateway/src/graphql/schema.d.ts @@ -50,7 +50,6 @@ export interface GQLQuery { getEventsWithProgress?: GQLEventProgressResultSet getSystemRoles?: Array fetchSystem?: GQLSystem - getEvent: GQLEvent } export interface GQLMutation { @@ -108,18 +107,6 @@ export interface GQLMutation { deleteSystem?: GQLSystem bookmarkAdvancedSearch?: GQLBookMarkedSearches removeBookmarkedAdvancedSearch?: GQLBookMarkedSearches - createEvent: GQLEvent - notifyEvent: GQLEvent - declareEvent: GQLEvent - registerEvent: GQLEvent - certifyEvent: GQLEvent - issueEvent: GQLEvent - revokeEvent: GQLEvent - reinstateEvent: GQLEvent - revokeCorrectionEvent: GQLEvent - requestCorrectionEvent: GQLEvent - approveCorrectionEvent: GQLEvent - rejectCorrectionEvent: GQLEvent } export interface GQLDummy { @@ -505,14 +492,6 @@ export interface GQLSystem { settings?: GQLSystemSettings } -export interface GQLEvent { - type: string - id: string - createdAt: GQLDateTime - updatedAt: GQLDateTime - actions: Array -} - export interface GQLCorrectionInput { requester: string requesterOther?: string @@ -684,54 +663,6 @@ export interface GQLRemoveBookmarkedSeachInput { searchId: string } -export interface GQLEventInput { - type: string -} - -export interface GQLNotifyActionInput { - data: Array -} - -export interface GQLDeclareActionInput { - data: Array -} - -export interface GQLRegisterActionInput { - data: Array -} - -export interface GQLCertifyActionInput { - data: Array -} - -export interface GQLIssueActionInput { - data: Array -} - -export interface GQLRevokeActionInput { - data: Array -} - -export interface GQLReinstateActionInput { - data: Array -} - -export interface GQLRevokeCorrectionActionInput { - data: Array -} - -export interface GQLRequestCorrectionActionInput { - data: Array -} - -export interface GQLApproveCorrectionActionInput { - data: Array -} - -export interface GQLRejectCorrectionActionInput { - data: Array -} - export type GQLMap = any export interface GQLRegistration { @@ -1092,29 +1023,6 @@ export interface GQLSystemSettings { openIdProviderClaims?: string } -export type GQLDateTime = any - -export type GQLAction = - | GQLCreateAction - | GQLRegisterAction - | GQLNotifyAction - | GQLDeclareAction - -/** Use this to resolve union type Action */ -export type GQLPossibleActionTypeNames = - | 'CreateAction' - | 'RegisterAction' - | 'NotifyAction' - | 'DeclareAction' - -export interface GQLActionNameMap { - Action: GQLAction - CreateAction: GQLCreateAction - RegisterAction: GQLRegisterAction - NotifyAction: GQLNotifyAction - DeclareAction: GQLDeclareAction -} - export interface GQLAttachmentInput { _fhirID?: string contentType?: string @@ -1311,11 +1219,6 @@ export interface GQLWebhookInput { permissions: Array } -export interface GQLFieldInput { - id: string - value: GQLFieldValue -} - export interface GQLAssignmentData { practitionerId?: string firstName?: string @@ -1599,36 +1502,6 @@ export interface GQLWebhookPermission { permissions: Array } -export interface GQLCreateAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array -} - -export interface GQLRegisterAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array - identifiers: GQLIdentifiers -} - -export interface GQLNotifyAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array -} - -export interface GQLDeclareAction { - type: string - createdAt: GQLDateTime - createdBy: string - data: Array - identifiers: GQLIdentifiers -} - export const enum GQLAttachmentInputStatus { approved = 'approved', validated = 'validated', @@ -1750,16 +1623,6 @@ export interface GQLAdditionalIdWithCompositionId { trackingId: string } -export interface GQLField { - id: string - value: GQLFieldValue -} - -export interface GQLIdentifiers { - trackingId: string - registrationNumber: string -} - export const enum GQLTelecomSystem { other = 'other', phone = 'phone', @@ -1860,7 +1723,6 @@ export interface GQLResolver { EventProgressResultSet?: GQLEventProgressResultSetTypeResolver SystemRole?: GQLSystemRoleTypeResolver System?: GQLSystemTypeResolver - Event?: GQLEventTypeResolver CreatedIds?: GQLCreatedIdsTypeResolver Reinstated?: GQLReinstatedTypeResolver Avatar?: GQLAvatarTypeResolver @@ -1904,11 +1766,6 @@ export interface GQLResolver { EventProgressSet?: GQLEventProgressSetTypeResolver SystemSettings?: GQLSystemSettingsTypeResolver - DateTime?: GraphQLScalarType - Action?: { - __resolveType: GQLActionTypeResolver - } - AssignmentData?: GQLAssignmentDataTypeResolver RegWorkflow?: GQLRegWorkflowTypeResolver Certificate?: GQLCertificateTypeResolver @@ -1932,18 +1789,12 @@ export interface GQLResolver { MarriageEventSearchSet?: GQLMarriageEventSearchSetTypeResolver EventProgressData?: GQLEventProgressDataTypeResolver WebhookPermission?: GQLWebhookPermissionTypeResolver - CreateAction?: GQLCreateActionTypeResolver - RegisterAction?: GQLRegisterActionTypeResolver - NotifyAction?: GQLNotifyActionTypeResolver - DeclareAction?: GQLDeclareActionTypeResolver FieldValue?: GraphQLScalarType AuditLogItemBase?: { __resolveType: GQLAuditLogItemBaseTypeResolver } AdditionalIdWithCompositionId?: GQLAdditionalIdWithCompositionIdTypeResolver - Field?: GQLFieldTypeResolver - Identifiers?: GQLIdentifiersTypeResolver } export interface GQLQueryTypeResolver { sendNotificationToAllUsers?: QueryToSendNotificationToAllUsersResolver @@ -1983,7 +1834,6 @@ export interface GQLQueryTypeResolver { getEventsWithProgress?: QueryToGetEventsWithProgressResolver getSystemRoles?: QueryToGetSystemRolesResolver fetchSystem?: QueryToFetchSystemResolver - getEvent?: QueryToGetEventResolver } export interface QueryToSendNotificationToAllUsersArgs { @@ -2585,18 +2435,6 @@ export interface QueryToFetchSystemResolver { ): TResult } -export interface QueryToGetEventArgs { - eventId: string -} -export interface QueryToGetEventResolver { - ( - parent: TParent, - args: QueryToGetEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLMutationTypeResolver { requestRegistrationCorrection?: MutationToRequestRegistrationCorrectionResolver rejectRegistrationCorrection?: MutationToRejectRegistrationCorrectionResolver @@ -2652,18 +2490,6 @@ export interface GQLMutationTypeResolver { deleteSystem?: MutationToDeleteSystemResolver bookmarkAdvancedSearch?: MutationToBookmarkAdvancedSearchResolver removeBookmarkedAdvancedSearch?: MutationToRemoveBookmarkedAdvancedSearchResolver - createEvent?: MutationToCreateEventResolver - notifyEvent?: MutationToNotifyEventResolver - declareEvent?: MutationToDeclareEventResolver - registerEvent?: MutationToRegisterEventResolver - certifyEvent?: MutationToCertifyEventResolver - issueEvent?: MutationToIssueEventResolver - revokeEvent?: MutationToRevokeEventResolver - reinstateEvent?: MutationToReinstateEventResolver - revokeCorrectionEvent?: MutationToRevokeCorrectionEventResolver - requestCorrectionEvent?: MutationToRequestCorrectionEventResolver - approveCorrectionEvent?: MutationToApproveCorrectionEventResolver - rejectCorrectionEvent?: MutationToRejectCorrectionEventResolver } export interface MutationToRequestRegistrationCorrectionArgs { @@ -3501,176 +3327,6 @@ export interface MutationToRemoveBookmarkedAdvancedSearchResolver< ): TResult } -export interface MutationToCreateEventArgs { - event: GQLEventInput -} -export interface MutationToCreateEventResolver { - ( - parent: TParent, - args: MutationToCreateEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToNotifyEventArgs { - eventId: string - input: GQLNotifyActionInput -} -export interface MutationToNotifyEventResolver { - ( - parent: TParent, - args: MutationToNotifyEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToDeclareEventArgs { - eventId: string - input: GQLDeclareActionInput -} -export interface MutationToDeclareEventResolver { - ( - parent: TParent, - args: MutationToDeclareEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRegisterEventArgs { - eventId: string - input: GQLRegisterActionInput -} -export interface MutationToRegisterEventResolver { - ( - parent: TParent, - args: MutationToRegisterEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToCertifyEventArgs { - eventId: string - input: GQLCertifyActionInput -} -export interface MutationToCertifyEventResolver { - ( - parent: TParent, - args: MutationToCertifyEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToIssueEventArgs { - eventId: string - input: GQLIssueActionInput -} -export interface MutationToIssueEventResolver { - ( - parent: TParent, - args: MutationToIssueEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRevokeEventArgs { - eventId: string - input: GQLRevokeActionInput -} -export interface MutationToRevokeEventResolver { - ( - parent: TParent, - args: MutationToRevokeEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToReinstateEventArgs { - eventId: string - input: GQLReinstateActionInput -} -export interface MutationToReinstateEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToReinstateEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRevokeCorrectionEventArgs { - eventId: string - input: GQLRevokeCorrectionActionInput -} -export interface MutationToRevokeCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToRevokeCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRequestCorrectionEventArgs { - eventId: string - input: GQLRequestCorrectionActionInput -} -export interface MutationToRequestCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToRequestCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToApproveCorrectionEventArgs { - eventId: string - input: GQLApproveCorrectionActionInput -} -export interface MutationToApproveCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToApproveCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface MutationToRejectCorrectionEventArgs { - eventId: string - input: GQLRejectCorrectionActionInput -} -export interface MutationToRejectCorrectionEventResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: MutationToRejectCorrectionEventArgs, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLDummyTypeResolver { dummy?: DummyToDummyResolver } @@ -5647,59 +5303,6 @@ export interface SystemToSettingsResolver { ): TResult } -export interface GQLEventTypeResolver { - type?: EventToTypeResolver - id?: EventToIdResolver - createdAt?: EventToCreatedAtResolver - updatedAt?: EventToUpdatedAtResolver - actions?: EventToActionsResolver -} - -export interface EventToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToIdResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToCreatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToUpdatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface EventToActionsResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLCreatedIdsTypeResolver { compositionId?: CreatedIdsToCompositionIdResolver trackingId?: CreatedIdsToTrackingIdResolver @@ -8213,16 +7816,6 @@ export interface SystemSettingsToOpenIdProviderClaimsResolver< ): TResult } -export interface GQLActionTypeResolver { - (parent: TParent, context: Context, info: GraphQLResolveInfo): - | 'CreateAction' - | 'RegisterAction' - | 'NotifyAction' - | 'DeclareAction' - | Promise< - 'CreateAction' | 'RegisterAction' | 'NotifyAction' | 'DeclareAction' - > -} export interface GQLAssignmentDataTypeResolver { practitionerId?: AssignmentDataToPractitionerIdResolver firstName?: AssignmentDataToFirstNameResolver @@ -10679,216 +10272,6 @@ export interface WebhookPermissionToPermissionsResolver< ): TResult } -export interface GQLCreateActionTypeResolver { - type?: CreateActionToTypeResolver - createdAt?: CreateActionToCreatedAtResolver - createdBy?: CreateActionToCreatedByResolver - data?: CreateActionToDataResolver -} - -export interface CreateActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface CreateActionToCreatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface CreateActionToCreatedByResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface CreateActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLRegisterActionTypeResolver { - type?: RegisterActionToTypeResolver - createdAt?: RegisterActionToCreatedAtResolver - createdBy?: RegisterActionToCreatedByResolver - data?: RegisterActionToDataResolver - identifiers?: RegisterActionToIdentifiersResolver -} - -export interface RegisterActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToCreatedAtResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToCreatedByResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface RegisterActionToIdentifiersResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLNotifyActionTypeResolver { - type?: NotifyActionToTypeResolver - createdAt?: NotifyActionToCreatedAtResolver - createdBy?: NotifyActionToCreatedByResolver - data?: NotifyActionToDataResolver -} - -export interface NotifyActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface NotifyActionToCreatedAtResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface NotifyActionToCreatedByResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface NotifyActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLDeclareActionTypeResolver { - type?: DeclareActionToTypeResolver - createdAt?: DeclareActionToCreatedAtResolver - createdBy?: DeclareActionToCreatedByResolver - data?: DeclareActionToDataResolver - identifiers?: DeclareActionToIdentifiersResolver -} - -export interface DeclareActionToTypeResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToCreatedAtResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToCreatedByResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToDataResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface DeclareActionToIdentifiersResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - export interface GQLAuditLogItemBaseTypeResolver { (parent: TParent, context: Context, info: GraphQLResolveInfo): | 'UserAuditLogItemWithComposition' @@ -10923,52 +10306,3 @@ export interface AdditionalIdWithCompositionIdToTrackingIdResolver< info: GraphQLResolveInfo ): TResult } - -export interface GQLFieldTypeResolver { - id?: FieldToIdResolver - value?: FieldToValueResolver -} - -export interface FieldToIdResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface FieldToValueResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface GQLIdentifiersTypeResolver { - trackingId?: IdentifiersToTrackingIdResolver - registrationNumber?: IdentifiersToRegistrationNumberResolver -} - -export interface IdentifiersToTrackingIdResolver { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} - -export interface IdentifiersToRegistrationNumberResolver< - TParent = any, - TResult = any -> { - ( - parent: TParent, - args: {}, - context: Context, - info: GraphQLResolveInfo - ): TResult -} diff --git a/packages/gateway/src/graphql/schema.graphql b/packages/gateway/src/graphql/schema.graphql index af29cf90c26..5446faebcd7 100644 --- a/packages/gateway/src/graphql/schema.graphql +++ b/packages/gateway/src/graphql/schema.graphql @@ -147,7 +147,6 @@ type Query { sortOrder: String ): [SystemRole!] fetchSystem(clientId: ID!): System - getEvent(eventId: ID!): Event! } type Mutation { @@ -270,30 +269,6 @@ type Mutation { removeBookmarkedAdvancedSearch( removeBookmarkedSearchInput: RemoveBookmarkedSeachInput! ): BookMarkedSearches - createEvent(event: EventInput!): Event! - notifyEvent(eventId: ID!, input: NotifyActionInput!): Event! - declareEvent(eventId: ID!, input: DeclareActionInput!): Event! - registerEvent(eventId: ID!, input: RegisterActionInput!): Event! - certifyEvent(eventId: ID!, input: CertifyActionInput!): Event! - issueEvent(eventId: ID!, input: IssueActionInput!): Event! - revokeEvent(eventId: ID!, input: RevokeActionInput!): Event! - reinstateEvent(eventId: ID!, input: ReinstateActionInput!): Event! - revokeCorrectionEvent( - eventId: ID! - input: RevokeCorrectionActionInput! - ): Event! - requestCorrectionEvent( - eventId: ID! - input: RequestCorrectionActionInput! - ): Event! - approveCorrectionEvent( - eventId: ID! - input: ApproveCorrectionActionInput! - ): Event! - rejectCorrectionEvent( - eventId: ID! - input: RejectCorrectionActionInput! - ): Event! } type Dummy { @@ -642,14 +617,6 @@ type System { settings: SystemSettings } -type Event { - type: String! - id: String! - createdAt: DateTime! - updatedAt: DateTime! - actions: [Action!]! -} - input CorrectionInput { requester: String! requesterOther: String @@ -821,54 +788,6 @@ input RemoveBookmarkedSeachInput { searchId: String! } -input EventInput { - type: String! -} - -input NotifyActionInput { - data: [FieldInput!]! -} - -input DeclareActionInput { - data: [FieldInput!]! -} - -input RegisterActionInput { - data: [FieldInput!]! -} - -input CertifyActionInput { - data: [FieldInput!]! -} - -input IssueActionInput { - data: [FieldInput!]! -} - -input RevokeActionInput { - data: [FieldInput!]! -} - -input ReinstateActionInput { - data: [FieldInput!]! -} - -input RevokeCorrectionActionInput { - data: [FieldInput!]! -} - -input RequestCorrectionActionInput { - data: [FieldInput!]! -} - -input ApproveCorrectionActionInput { - data: [FieldInput!]! -} - -input RejectCorrectionActionInput { - data: [FieldInput!]! -} - scalar Map type Registration { @@ -1205,10 +1124,6 @@ type SystemSettings { openIdProviderClaims: String } -scalar DateTime - -union Action = CreateAction | RegisterAction | NotifyAction | DeclareAction - input AttachmentInput { _fhirID: ID contentType: String @@ -1405,11 +1320,6 @@ input WebhookInput { permissions: [String]! } -input FieldInput { - id: String! - value: FieldValue! -} - type AssignmentData { practitionerId: String firstName: String @@ -1692,36 +1602,6 @@ type WebhookPermission { permissions: [String!]! } -type CreateAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! -} - -type RegisterAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! - identifiers: Identifiers! -} - -type NotifyAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! -} - -type DeclareAction { - type: String! - createdAt: DateTime! - createdBy: String! - data: [Field!]! - identifiers: Identifiers! -} - enum AttachmentInputStatus { approved validated @@ -1832,16 +1712,6 @@ type AdditionalIdWithCompositionId { trackingId: String! } -type Field { - id: String! - value: FieldValue! -} - -type Identifiers { - trackingId: String! - registrationNumber: String! -} - enum TelecomSystem { other phone diff --git a/yarn.lock b/yarn.lock index 226e84c38e2..3221ba107ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9314,7 +9314,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@18.3.1", "@types/react@>=16", "@types/react@^16": +"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@18.3.1", "@types/react@>=16": version "18.3.1" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.1.tgz#fed43985caa834a2084d002e4771e15dfcbdbe8e" integrity sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw== @@ -9322,6 +9322,15 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/react@^16": + version "16.14.62" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.62.tgz#449e4e81caaf132d0c2c390644e577702db1dd9e" + integrity sha512-BWf7hqninZav6nerxXj+NeZT/mTpDeG6Lk2zREHAy63CrnXoOGPGtNqTFYFN/sqpSaREDP5otVV88axIXmKfGA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "^0.16" + csstype "^3.0.2" + "@types/readdir-glob@*": version "1.1.3" resolved "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.3.tgz" @@ -9390,6 +9399,11 @@ dependencies: htmlparser2 "^8.0.0" +"@types/scheduler@^0.16": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" + integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== + "@types/semver@^7.3.4": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" @@ -15073,7 +15087,14 @@ fast-url-parser@1.1.3, fast-url-parser@^1.1.3: dependencies: punycode "^1.3.2" -fast-xml-parser@4.2.5, fast-xml-parser@4.4.1, fast-xml-parser@^4.1.3, fast-xml-parser@^4.2.2: +fast-xml-parser@4.2.5: + version "4.2.5" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz#a6747a09296a6cb34f2ae634019bf1738f3b421f" + integrity sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g== + dependencies: + strnum "^1.0.5" + +fast-xml-parser@^4.1.3, fast-xml-parser@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== @@ -19656,6 +19677,30 @@ msw@^2.6.8: type-fest "^4.26.1" yargs "^17.7.2" +msw@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/msw/-/msw-2.7.0.tgz#d13ff87f7e018fc4c359800ff72ba5017033fb56" + integrity sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw== + dependencies: + "@bundled-es-modules/cookie" "^2.0.1" + "@bundled-es-modules/statuses" "^1.0.1" + "@bundled-es-modules/tough-cookie" "^0.1.6" + "@inquirer/confirm" "^5.0.0" + "@mswjs/interceptors" "^0.37.0" + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/until" "^2.1.0" + "@types/cookie" "^0.6.0" + "@types/statuses" "^2.0.4" + graphql "^16.8.1" + headers-polyfill "^4.0.2" + is-node-process "^1.2.0" + outvariant "^1.4.3" + path-to-regexp "^6.3.0" + picocolors "^1.1.1" + strict-event-emitter "^0.5.1" + type-fest "^4.26.1" + yargs "^17.7.2" + multimatch@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" @@ -20974,6 +21019,11 @@ picocolors@^1.1.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59" integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatch@^2.3.0, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"