From fe55bfa2a43a1fff235aa97630a7c11ac6995ff9 Mon Sep 17 00:00:00 2001 From: Omri Levy <61207713+Omri-Levy@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:19:59 +0200 Subject: [PATCH] Editable entity data (#2875) * feat(*) added a cell that can render and edit context dynamically * refactor(*) updated editable details v2 api, added disabled states, added sorting * chore(*) updated packages * feat(*): ran format --- apps/backoffice-v2/CHANGELOG.md | 9 + apps/backoffice-v2/package.json | 10 +- .../public/locales/en/toast.json | 4 + .../EditableDetailsV2/EditableDetailsV2.tsx | 114 +++++++++++ .../components/EditableDetailV2.tsx | 188 ++++++++++++++++++ .../components/EditableDetailsV2Options.tsx | 47 +++++ .../components/ReadOnlyDetailV2.tsx | 24 +++ .../organisms/EditableDetailsV2/constants.ts | 1 + .../useEditableDetailsV2Logic.tsx | 141 +++++++++++++ .../organisms/EditableDetailsV2/types.ts | 75 +++++++ .../generate-editable-details-v2-fields.ts | 35 ++++ .../utils/get-display-value.ts | 21 ++ .../EditableDetailsV2/utils/get-input-type.ts | 39 ++++ .../utils/get-property-path.ts | 38 ++++ .../EditableDetailsV2/utils/is-path-match.ts | 47 +++++ .../domains/workflow-definitions/fetchers.ts | 9 + .../src/domains/workflows/fetchers.ts | 17 ++ .../useUpdateContextAndSyncEntity.tsx | 34 ++++ .../EditableDetailsV2Cell.tsx | 10 + .../hooks/useKycBlock/useKycBlock.tsx | 128 +++++++++--- .../create-blocks-typed.ts | 2 + .../lib/blocks/create-blocks-typed/types.ts | 10 +- .../src/lib/blocks/utils/sort-data.ts | 9 +- .../src/pages/Root/Root.page.tsx | 6 +- apps/kyb-app/CHANGELOG.md | 8 + apps/kyb-app/package.json | 6 +- examples/headless-example/CHANGELOG.md | 8 + examples/headless-example/package.json | 6 +- packages/common/CHANGELOG.md | 6 + packages/common/package.json | 2 +- .../merchant-screening-plugin-schema.ts | 24 ++- .../documents/schemas/entity-schema.ts | 144 +++++++------- packages/common/src/schemas/index.ts | 1 + packages/workflow-core/CHANGELOG.md | 7 + packages/workflow-core/package.json | 4 +- pnpm-lock.yaml | 44 ++-- sdks/web-ui-sdk/CHANGELOG.md | 7 + sdks/web-ui-sdk/package.json | 4 +- sdks/workflow-browser-sdk/CHANGELOG.md | 8 + sdks/workflow-browser-sdk/package.json | 6 +- sdks/workflow-node-sdk/CHANGELOG.md | 6 + sdks/workflow-node-sdk/package.json | 4 +- services/workflows-service/CHANGELOG.md | 9 + services/workflows-service/package.json | 8 +- .../workflows-service/prisma/data-migrations | 2 +- .../src/workflow/schemas/zod-schemas.ts | 9 + .../workflow/workflow.controller.external.ts | 46 ++++- .../src/workflow/workflow.service.ts | 78 ++++++++ websites/docs/package.json | 2 +- 49 files changed, 1300 insertions(+), 167 deletions(-) create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/EditableDetailsV2.tsx create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailV2.tsx create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailsV2Options.tsx create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/ReadOnlyDetailV2.tsx create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/constants.ts create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic.tsx create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/types.ts create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/generate-editable-details-v2-fields.ts create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-display-value.ts create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-input-type.ts create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-property-path.ts create mode 100644 apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/is-path-match.ts create mode 100644 apps/backoffice-v2/src/domains/workflows/hooks/mutations/useUpdateContextAndSyncEntity/useUpdateContextAndSyncEntity.tsx create mode 100644 apps/backoffice-v2/src/lib/blocks/components/EditableDetailsV2Cell/EditableDetailsV2Cell.tsx diff --git a/apps/backoffice-v2/CHANGELOG.md b/apps/backoffice-v2/CHANGELOG.md index 10da0238a2..4b29a244c8 100644 --- a/apps/backoffice-v2/CHANGELOG.md +++ b/apps/backoffice-v2/CHANGELOG.md @@ -1,5 +1,14 @@ # @ballerine/backoffice-v2 +## 0.7.79 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + - @ballerine/workflow-browser-sdk@0.6.75 + - @ballerine/workflow-node-sdk@0.6.75 + ## 0.7.78 ### Patch Changes diff --git a/apps/backoffice-v2/package.json b/apps/backoffice-v2/package.json index 8c98a9713a..39e5d28999 100644 --- a/apps/backoffice-v2/package.json +++ b/apps/backoffice-v2/package.json @@ -1,6 +1,6 @@ { "name": "@ballerine/backoffice-v2", - "version": "0.7.78", + "version": "0.7.79", "description": "Ballerine - Backoffice", "homepage": "https://github.com/ballerine-io/ballerine", "type": "module", @@ -52,11 +52,11 @@ }, "dependencies": { "@ballerine/blocks": "0.2.28", - "@ballerine/common": "0.9.56", + "@ballerine/common": "0.9.57", "@ballerine/react-pdf-toolkit": "^1.2.48", "@ballerine/ui": "^0.5.48", - "@ballerine/workflow-browser-sdk": "0.6.74", - "@ballerine/workflow-node-sdk": "0.6.74", + "@ballerine/workflow-browser-sdk": "0.6.75", + "@ballerine/workflow-node-sdk": "0.6.75", "@botpress/webchat": "^2.1.10", "@botpress/webchat-generator": "^0.2.9", "@fontsource/inter": "^4.5.15", @@ -117,6 +117,7 @@ "i18next-http-backend": "^2.1.1", "leaflet": "^1.9.4", "libphonenumber-js": "^1.10.49", + "lodash-es": "^4.17.21", "lowlight": "^3.1.0", "lucide-react": "0.445.0", "match-sorter": "^6.3.1", @@ -167,6 +168,7 @@ "@types/d3-hierarchy": "^3.1.7", "@types/dompurify": "^3.0.5", "@types/leaflet": "^1.9.3", + "@types/lodash-es": "^4.17.12", "@types/node": "^18.11.13", "@types/qs": "^6.9.7", "@types/react": "^18.0.14", diff --git a/apps/backoffice-v2/public/locales/en/toast.json b/apps/backoffice-v2/public/locales/en/toast.json index 7c6a044557..e7aa7685c7 100644 --- a/apps/backoffice-v2/public/locales/en/toast.json +++ b/apps/backoffice-v2/public/locales/en/toast.json @@ -100,5 +100,9 @@ "note_created": { "success": "Note added successfully.", "error": "Error occurred while adding note." + }, + "update_details": { + "success": "Details updated successfully.", + "error": "Error occurred while updating details." } } diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/EditableDetailsV2.tsx b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/EditableDetailsV2.tsx new file mode 100644 index 0000000000..286298bf75 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/EditableDetailsV2.tsx @@ -0,0 +1,114 @@ +import { Button, TextWithNAFallback } from '@ballerine/ui'; + +import { FormField } from '../Form/Form.Field'; +import { titleCase } from 'string-ts'; +import { Form } from '../Form/Form'; +import { FunctionComponent } from 'react'; +import { FormItem } from '../Form/Form.Item'; +import { FormLabel } from '../Form/Form.Label'; +import { FormMessage } from '../Form/Form.Message'; +import { useNewEditableDetailsLogic } from './hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic'; +import { EditableDetailsV2Options } from './components/EditableDetailsV2Options'; +import { EditableDetailV2 } from './components/EditableDetailV2'; +import { IEditableDetailsV2Props } from './types'; + +export const EditableDetailsV2: FunctionComponent = ({ + title, + fields, + onSubmit, + onEnableIsEditable, + onCancel, + config, +}) => { + if (config.blacklist && config.whitelist) { + throw new Error('Cannot provide both blacklist and whitelist'); + } + + const { form, handleSubmit, filteredFields } = useNewEditableDetailsLogic({ + fields, + onSubmit, + config, + }); + + return ( +
+
+

{title}

+ +
+
+ +
+ {title} + {filteredFields.map(({ title, path, props }) => { + const originalValue = form.watch(path); + + return ( + ( + + + {titleCase(title ?? '')} + + + + + )} + /> + ); + })} +
+
+ {!config.actions.editing.disabled && + filteredFields?.some(({ props }) => props.isEditable) && ( + + )} + {!config.actions.editing.disabled && + filteredFields?.some(({ props }) => props.isEditable) && ( + + )} +
+
+ +
+ ); +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailV2.tsx b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailV2.tsx new file mode 100644 index 0000000000..2db981898a --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailV2.tsx @@ -0,0 +1,188 @@ +import { FunctionComponent, ComponentProps, useCallback, ChangeEvent } from 'react'; +import { FieldValues, UseFormReturn } from 'react-hook-form'; +import { ExtendedJson } from '@/common/types'; +import { isValidDatetime } from '@/common/utils/is-valid-datetime'; +import { FileJson2 } from 'lucide-react'; +import { JsonDialog, ctw, BallerineLink, checkIsDate } from '@ballerine/ui'; +import { isObject, isNullish, checkIsIsoDate, checkIsUrl } from '@ballerine/common'; +import { Input } from '@ballerine/ui'; +import { Select } from '../../../atoms/Select/Select'; +import { SelectTrigger } from '../../../atoms/Select/Select.Trigger'; +import { SelectValue } from '../../../atoms/Select/Select.Value'; +import { SelectContent } from '../../../atoms/Select/Select.Content'; +import { SelectItem } from '../../../atoms/Select/Select.Item'; +import { keyFactory } from '@/common/utils/key-factory/key-factory'; +import { Checkbox_ } from '../../../atoms/Checkbox_/Checkbox_'; +import dayjs from 'dayjs'; +import { ReadOnlyDetailV2 } from './ReadOnlyDetailV2'; +import { getDisplayValue } from '../utils/get-display-value'; +import { FormField } from '../../Form/Form.Field'; +import { FormControl } from '../../Form/Form.Control'; +import { getInputType } from '../utils/get-input-type'; + +export const EditableDetailV2: FunctionComponent<{ + isEditable: boolean; + className?: string; + options?: Array<{ + label: string; + value: string; + }>; + form: UseFormReturn; + field: Parameters['render']>[0]['field']; + valueAlias?: string; + originalValue: ExtendedJson; + type: string | undefined; + format: string | undefined; + minimum?: number; + maximum?: number; + pattern?: string; + parse?: { + date?: boolean; + isoDate?: boolean; + datetime?: boolean; + boolean?: boolean; + url?: boolean; + nullish?: boolean; + }; +}> = ({ + isEditable, + className, + options, + originalValue, + form, + field, + valueAlias, + type, + format, + minimum, + maximum, + pattern, + parse, +}) => { + const displayValue = getDisplayValue({ value: field.value, originalValue, isEditable }); + const onInputChange = useCallback( + (event: ChangeEvent) => { + const value = event.target.value === 'N/A' ? '' : event.target.value; + + form.setValue(field.name, value); + }, + [field.name, form], + ); + + if (Array.isArray(field.value) || isObject(field.value)) { + return ( +
+ } + dialogButtonText={`View Information`} + json={JSON.stringify(field.value)} + /> +
+ ); + } + + if (isEditable && options) { + return ( + + ); + } + + if (parse?.boolean && (typeof field.value === 'boolean' || type === 'boolean')) { + return ( + + + + ); + } + + if (isEditable) { + const inputType = getInputType({ format, type, value: originalValue }); + + return ( + + + + ); + } + + if (typeof field.value === 'boolean' || type === 'boolean') { + return {`${field.value}`}; + } + + if (parse?.url && checkIsUrl(field.value)) { + return ( + + {valueAlias ?? field.value} + + ); + } + + if (parse?.datetime && (isValidDatetime(field.value) || type === 'date-time')) { + const value = field.value.endsWith(':00') ? field.value : `${field.value}:00`; + + return ( + + {dayjs(value).utc().format('DD/MM/YYYY HH:mm')} + + ); + } + + if ( + (parse?.date && checkIsDate(field.value, { isStrict: false })) || + (parse?.isoDate && checkIsIsoDate(field.value)) || + (type === 'date' && (parse?.date || parse?.isoDate)) + ) { + return ( + + {dayjs(field.value).format('DD/MM/YYYY')} + + ); + } + + if (parse?.nullish && isNullish(field.value)) { + return {field.value}; + } + + if (isNullish(field.value)) { + return {`${field.value}`}; + } + + return {field.value}; +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailsV2Options.tsx b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailsV2Options.tsx new file mode 100644 index 0000000000..990cc260d9 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/EditableDetailsV2Options.tsx @@ -0,0 +1,47 @@ +import { + DropdownMenuContent, + Button, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuItem, +} from '@ballerine/ui'; +import { Edit } from 'lucide-react'; +import { FunctionComponent } from 'react'; + +export const EditableDetailsV2Options: FunctionComponent<{ + actions: { + options: { + disabled: boolean; + }; + enableEditing: { + disabled: boolean; + }; + }; + onEnableIsEditable: () => void; +}> = ({ actions, onEnableIsEditable }) => { + return ( + + + + + + + + + + + ); +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/ReadOnlyDetailV2.tsx b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/ReadOnlyDetailV2.tsx new file mode 100644 index 0000000000..001c54efeb --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/components/ReadOnlyDetailV2.tsx @@ -0,0 +1,24 @@ +import { TextWithNAFallback, ctw } from '@ballerine/ui'; +import { FunctionComponent, ComponentProps } from 'react'; + +export const ReadOnlyDetailV2: FunctionComponent> = ({ + children, + className, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/constants.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/constants.ts new file mode 100644 index 0000000000..5c1dcc1062 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/constants.ts @@ -0,0 +1 @@ +export const __ROOT__ = '__ROOT__'; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic.tsx b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic.tsx new file mode 100644 index 0000000000..4732a640f7 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic.tsx @@ -0,0 +1,141 @@ +import { ComponentProps, useCallback, useMemo } from 'react'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { EditableDetailsV2 } from '../../EditableDetailsV2'; +import { isPathMatch } from '../../utils/is-path-match'; +import { isObject } from '@ballerine/common'; +import { set, get } from 'lodash-es'; +import { sortData } from '@/lib/blocks/utils/sort-data'; + +export const useNewEditableDetailsLogic = ({ + fields, + onSubmit, + config, +}: Pick, 'fields' | 'onSubmit' | 'config'>) => { + const sortedFields = useMemo( + () => + sortData({ + data: fields, + direction: config?.sort?.direction, + predefinedOrder: config?.sort?.predefinedOrder, + }), + [fields, config?.sort?.direction, config?.sort?.predefinedOrder], + ); + // Should support multiple levels of nesting, arrays, objects, and multiple path syntaxes + const filterValue = useCallback( + ({ path, root }: { path: string; root: string }) => + (value: any): any => { + if (!config.blacklist && !config.whitelist) { + return value; + } + + if (isObject(value)) { + return Object.entries(value).reduce((acc, [key, value]) => { + const fullPath = `${path}.${key}`; + const isBlacklisted = config.blacklist?.some(pattern => + isPathMatch({ + pattern, + path: fullPath, + root, + }), + ); + const isWhitelisted = + !config.whitelist || + config.whitelist?.some(pattern => + isPathMatch({ + pattern, + path: fullPath, + root, + }), + ); + + if (isBlacklisted) { + return acc; + } + + if (isWhitelisted) { + acc[key] = filterValue({ path: fullPath, root })(value); + } + + return acc; + }, {} as Record); + } + + if (Array.isArray(value)) { + return value.map((item, index) => filterValue({ path: `${path}.${index}`, root })(item)); + } + + return value; + }, + [config.blacklist, config.whitelist], + ); + + const filteredFields = useMemo(() => { + return sortedFields.filter(field => { + if (config.blacklist) { + return !config.blacklist.some(pattern => + isPathMatch({ + pattern, + path: field.path, + root: field.root, + }), + ); + } + + if (config.whitelist) { + return config.whitelist.some(pattern => + isPathMatch({ + pattern, + path: field.path, + root: field.root, + }), + ); + } + + return true; + }); + }, [sortedFields, config.blacklist, config.whitelist]); + const defaultValues = useMemo( + () => + filteredFields.reduce((acc, curr) => { + set(acc, curr.path, curr.value); + + return acc; + }, {} as Record), + [filteredFields], + ); + const form = useForm({ + defaultValues, + }); + + const handleSubmit: SubmitHandler> = useCallback( + values => { + const updatedData = fields.reduce((acc, curr) => { + const value = get(values, curr.path); + const defaultValue = get(defaultValues, curr.path); + + if (value === defaultValue) { + return acc; + } + + if (curr.id) { + const pathToObject = curr.path.split('.').slice(0, -1).join('.'); + + set(acc, `${pathToObject}.id`, curr.id); + } + + set(acc, curr.path, value); + + return acc; + }, {} as Record); + + onSubmit(updatedData); + }, + [fields, defaultValues, onSubmit], + ); + + return { + form, + handleSubmit, + filteredFields, + }; +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/types.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/types.ts new file mode 100644 index 0000000000..c26882fe22 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/types.ts @@ -0,0 +1,75 @@ +import { SortDirection } from '@ballerine/common'; + +export interface IBaseEditableDetailsV2Config { + parse?: { + date?: boolean; + isoDate?: boolean; + datetime?: boolean; + boolean?: boolean; + url?: boolean; + nullish?: boolean; + }; + sort?: { + direction?: SortDirection; + predefinedOrder?: string[]; + }; + actions: { + editing: { + disabled: boolean; + }; + options: { + disabled: boolean; + }; + enableEditing: { + disabled: boolean; + }; + cancel: { + disabled: boolean; + }; + save: { + disabled: boolean; + }; + }; +} + +export interface IEditableDetailsV2ConfigWithBlacklist extends IBaseEditableDetailsV2Config { + blacklist: string[]; + whitelist?: never; +} + +export interface IEditableDetailsV2ConfigWithWhitelist extends IBaseEditableDetailsV2Config { + blacklist?: never; + whitelist: string[]; +} + +export type TEditableDetailsV2Config = + | IEditableDetailsV2ConfigWithBlacklist + | IEditableDetailsV2ConfigWithWhitelist; + +export interface IEditableDetailsV2Props { + title: string; + fields: Array<{ + id?: string; + title: string; + value: any; + props: { + valueAlias?: string; + type: string | undefined; + format: string | undefined; + isEditable: boolean; + pattern?: string; + minimum?: number; + maximum?: number; + options?: Array<{ + label: string; + value: string; + }>; + }; + path: string; + root: string; + }>; + onSubmit: (values: Record) => void; + onEnableIsEditable: () => void; + onCancel: () => void; + config: TEditableDetailsV2Config; +} diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/generate-editable-details-v2-fields.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/generate-editable-details-v2-fields.ts new file mode 100644 index 0000000000..c99185d233 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/generate-editable-details-v2-fields.ts @@ -0,0 +1,35 @@ +import { __ROOT__ } from '../constants'; +import { get } from 'lodash-es'; +import { getPropertyPath } from './get-property-path'; + +export const generateEditableDetailsV2Fields = + (obj: Record) => + ({ path, id }: { path: string; id?: string }) => { + const isWildcardPath = path === '*'; + const objectAtPath = isWildcardPath ? obj : get(obj, path); + const fields = Object.keys(objectAtPath).map(key => { + const pathToValue = isWildcardPath ? key : `${path}.${key}`; + const propertyPath = getPropertyPath({ + obj, + accessor: proxy => get(proxy, pathToValue), + propertyId: id, + }); + const root = isWildcardPath ? __ROOT__ : path; + + if (!root) { + throw new Error('Root is undefined'); + } + + return { + ...propertyPath, + root, + props: { + type: undefined, + format: undefined, + isEditable: true, + }, + }; + }); + + return fields; + }; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-display-value.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-display-value.ts new file mode 100644 index 0000000000..8bef9ebaf3 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-display-value.ts @@ -0,0 +1,21 @@ +import { isNullish } from '@ballerine/common'; + +export const getDisplayValue = ({ + value, + originalValue, + isEditable, +}: { + value: TValue; + originalValue: TOriginalValue; + isEditable: boolean; +}) => { + if (isEditable) { + return originalValue; + } + + if (isNullish(value) || value === '') { + return 'N/A'; + } + + return value; +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-input-type.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-input-type.ts new file mode 100644 index 0000000000..1157e3f4bb --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-input-type.ts @@ -0,0 +1,39 @@ +import { isValidDatetime } from '@/common/utils/is-valid-datetime'; +import { checkIsIsoDate } from '@ballerine/common'; +import { checkIsDate } from '@ballerine/ui'; + +export const getInputType = ({ + format, + type, + value, +}: { + format: string | undefined; + type: string | undefined; + value: unknown; +}) => { + if (format === 'date-time' || isValidDatetime(value)) { + return 'datetime-local'; + } + + if (format) { + return format; + } + + if (type === 'string') { + return 'text'; + } + + if (type === 'number' || (typeof value === 'number' && Number.isFinite(value))) { + return 'number'; + } + + if (checkIsDate(value, { isStrict: false }) || checkIsIsoDate(value) || type === 'date') { + return 'date'; + } + + if (!type) { + return 'text'; + } + + return type; +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-property-path.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-property-path.ts new file mode 100644 index 0000000000..6b400e1fca --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/get-property-path.ts @@ -0,0 +1,38 @@ +import { get } from 'lodash-es'; + +export const getPropertyPath = >({ + obj, + accessor, + propertyId, +}: { + obj: TObj; + accessor: (proxy: TObj) => any; + propertyId?: string; +}) => { + const path: string[] = []; + + const proxy = new Proxy(obj, { + get(target: TObj, prop: PropertyKey) { + path.push(String(prop)); + + return new Proxy({}, this); + }, + }); + + // Invoke the accessor function to trigger the proxy + accessor(proxy); + + const fullPath = path.join('.'); + const prop = path.at(-1); + + if (!prop) { + throw new Error('Property path is empty'); + } + + return { + id: propertyId, + title: prop, + value: get(obj, path.join('.')), + path: fullPath, + }; +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/is-path-match.ts b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/is-path-match.ts new file mode 100644 index 0000000000..f9edbb51fc --- /dev/null +++ b/apps/backoffice-v2/src/common/components/organisms/EditableDetailsV2/utils/is-path-match.ts @@ -0,0 +1,47 @@ +import { __ROOT__ } from '../constants'; + +export const isPathMatch = ({ + pattern, + path, + root, +}: { + pattern: string; + path: string; + root: string; +}) => { + const patternParts = pattern.split('.'); + + // Exact matches, no wildcards. + if (!pattern.includes('*') && patternParts.length > 1) { + return pattern === path; + } + + /** + * @example pattern: 'id', path: 'entity.id' where root is 'entity' + * */ + if (patternParts.length === 1 && path === `${root}.${pattern}`) { + return true; + } + + // Match any path not at the root level. + if (pattern.startsWith('*.')) { + const parts = path.split('.'); + const suffix = pattern.slice(2); + + // parts.length > 2 ensures we have at least one level between root and the target field + return (parts.length > 2 || root === __ROOT__) && path.endsWith(suffix); + } + + const regexPattern = + pattern + // Escape dots for the regex + .replace(/\./g, '\\.') + // Replace * with regex pattern that matches any characters except dots + .replace(/\*/g, '[^.]+') + + // Make the pattern match both exact and partial paths + '(?:\\.[^.]+)*'; + + const regex = new RegExp(`^${regexPattern}$`); + + return regex.test(path); +}; diff --git a/apps/backoffice-v2/src/domains/workflow-definitions/fetchers.ts b/apps/backoffice-v2/src/domains/workflow-definitions/fetchers.ts index e353beead5..16cb3de6f1 100644 --- a/apps/backoffice-v2/src/domains/workflow-definitions/fetchers.ts +++ b/apps/backoffice-v2/src/domains/workflow-definitions/fetchers.ts @@ -47,6 +47,15 @@ export const WorkflowDefinitionConfigSchema = z .optional(), }) .optional(), + editableContext: z + .object({ + kyc: z + .object({ + entity: z.boolean().optional(), + }) + .optional(), + }) + .optional(), }) .passthrough() .nullable(); diff --git a/apps/backoffice-v2/src/domains/workflows/fetchers.ts b/apps/backoffice-v2/src/domains/workflows/fetchers.ts index ac1f9ab036..af75288bb8 100644 --- a/apps/backoffice-v2/src/domains/workflows/fetchers.ts +++ b/apps/backoffice-v2/src/domains/workflows/fetchers.ts @@ -13,6 +13,23 @@ import { z } from 'zod'; import { IWorkflowId } from './interfaces'; import { zPropertyKey } from '@/lib/zod/utils/z-property-key/z-property-key'; +export const updateContextAndSyncEntity = async ({ + workflowId, + data, +}: { + workflowId: string; + data: Partial; +}) => { + const [workflow, error] = await apiClient({ + endpoint: `../external/workflows/${workflowId}/sync-entity`, + method: Method.PATCH, + body: data, + schema: z.undefined(), + }); + + return handleZodError(error, workflow); +}; + export const fetchWorkflows = async (params: { filterId: string; orderBy: string; diff --git a/apps/backoffice-v2/src/domains/workflows/hooks/mutations/useUpdateContextAndSyncEntity/useUpdateContextAndSyncEntity.tsx b/apps/backoffice-v2/src/domains/workflows/hooks/mutations/useUpdateContextAndSyncEntity/useUpdateContextAndSyncEntity.tsx new file mode 100644 index 0000000000..a0de2d8918 --- /dev/null +++ b/apps/backoffice-v2/src/domains/workflows/hooks/mutations/useUpdateContextAndSyncEntity/useUpdateContextAndSyncEntity.tsx @@ -0,0 +1,34 @@ +import { TWorkflowById, updateContextAndSyncEntity } from '@/domains/workflows/fetchers'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { t } from 'i18next'; +import { workflowsQueryKeys } from '../../../query-keys'; + +export const useUpdateContextAndSyncEntityMutation = ({ + workflowId, + onSuccess, +}: { + workflowId: string; + onSuccess: (data: null, variables: Partial, context: unknown) => void; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial) => + await updateContextAndSyncEntity({ + workflowId, + data, + }), + onSuccess: (...args) => { + void queryClient.invalidateQueries(workflowsQueryKeys._def); + + toast.success(t('toast:update_details.success')); + + onSuccess(...args); + }, + onError: () => { + toast.error(t('toast:update_details.error')); + }, + }); +}; diff --git a/apps/backoffice-v2/src/lib/blocks/components/EditableDetailsV2Cell/EditableDetailsV2Cell.tsx b/apps/backoffice-v2/src/lib/blocks/components/EditableDetailsV2Cell/EditableDetailsV2Cell.tsx new file mode 100644 index 0000000000..6021a618d5 --- /dev/null +++ b/apps/backoffice-v2/src/lib/blocks/components/EditableDetailsV2Cell/EditableDetailsV2Cell.tsx @@ -0,0 +1,10 @@ +import { FunctionComponent } from 'react'; +import { ExtractCellProps } from '@ballerine/blocks'; +import { EditableDetailsV2 } from '@/common/components/organisms/EditableDetailsV2/EditableDetailsV2'; + +export const EditableDetailsV2Cell: FunctionComponent> = ({ + value, + props, +}) => { + return ; +}; diff --git a/apps/backoffice-v2/src/lib/blocks/components/KycBlock/hooks/useKycBlock/useKycBlock.tsx b/apps/backoffice-v2/src/lib/blocks/components/KycBlock/hooks/useKycBlock/useKycBlock.tsx index 8e99a22f9b..9e72a13a21 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/KycBlock/hooks/useKycBlock/useKycBlock.tsx +++ b/apps/backoffice-v2/src/lib/blocks/components/KycBlock/hooks/useKycBlock/useKycBlock.tsx @@ -21,6 +21,9 @@ import { MotionBadge } from '../../../../../../common/components/molecules/Motio import { capitalize } from '../../../../../../common/utils/capitalize/capitalize'; import { useStorageFilesQuery } from '../../../../../../domains/storage/hooks/queries/useStorageFilesQuery/useStorageFilesQuery'; import { TWorkflowById } from '../../../../../../domains/workflows/fetchers'; +import { useToggle } from '@/common/hooks/useToggle/useToggle'; +import { generateEditableDetailsV2Fields } from '@/common/components/organisms/EditableDetailsV2/utils/generate-editable-details-v2-fields'; +import { useUpdateContextAndSyncEntityMutation } from '@/domains/workflows/hooks/mutations/useUpdateContextAndSyncEntity/useUpdateContextAndSyncEntity'; const motionBadgeProps = { exit: { opacity: 0, transition: { duration: 0.2 } }, @@ -175,15 +178,6 @@ export const useKycBlock = ({ ) ?? [] : []; - const details = Object.entries(childWorkflow?.context?.entity?.data ?? {}).map( - ([title, value]) => ({ - title, - value, - pattern: '', - isEditable: false, - dropdownOptions: undefined, - }), - ); const documents = childWorkflow?.context?.documents?.flatMap( (document, docIndex) => document?.pages?.map(({ type, metadata, data }, pageIndex) => ({ @@ -362,6 +356,101 @@ export const useKycBlock = ({ }) .cellAt(0, 0); + const fields = generateEditableDetailsV2Fields(childWorkflow?.context)({ + path: 'entity.data', + }); + + const [isEditable, _toggleIsEditable, toggleOnIsEditable, toggleOffIsEditable] = useToggle(); + const { mutate: mutateUpdateContextAndSyncEntity } = useUpdateContextAndSyncEntityMutation({ + workflowId: childWorkflow?.id, + onSuccess: () => { + toggleOffIsEditable(); + }, + }); + + const onSubmit = useCallback( + (values: Record) => { + mutateUpdateContextAndSyncEntity(values); + }, + [mutateUpdateContextAndSyncEntity], + ); + + const getEntityDataBlock = () => { + if (parentWorkflow?.workflowDefinition?.config?.editableContext?.kyc?.entity) { + return createBlocksTyped() + .addBlock() + .addCell({ + type: 'editableDetails', + value: fields, + props: { + title: 'Details', + onSubmit, + onEnableIsEditable: toggleOnIsEditable, + onCancel: toggleOffIsEditable, + config: { + parse: { + date: true, + isoDate: true, + datetime: true, + boolean: true, + url: true, + nullish: true, + }, + blacklist: [], + actions: { + options: { + disabled: !caseState.writeEnabled, + }, + enableEditing: { + disabled: isEditable, + }, + editing: { + disabled: !isEditable || !caseState.writeEnabled, + }, + cancel: { + disabled: false, + }, + save: { + disabled: !caseState.writeEnabled, + }, + }, + }, + }, + }) + .build() + .flat(1); + } + + return createBlocksTyped() + .addBlock() + .addCell({ + id: 'header', + type: 'heading', + value: 'Details', + }) + .addCell({ + id: 'decision', + type: 'details', + value: { + id: 1, + title: 'Details', + data: Object.entries(childWorkflow?.context?.entity?.data ?? {}).map( + ([title, value]) => ({ + title, + value, + pattern: '', + isEditable: false, + dropdownOptions: undefined, + }), + ), + }, + workflowId: childWorkflow?.id, + documents: childWorkflow?.context?.documents, + }) + .build() + .flat(1); + }; + return createBlocksTyped() .addBlock() .addCell({ @@ -409,26 +498,7 @@ export const useKycBlock = ({ .addBlock() .addCell({ type: 'container', - value: createBlocksTyped() - .addBlock() - .addCell({ - id: 'header', - type: 'heading', - value: 'Details', - }) - .addCell({ - id: 'decision', - type: 'details', - value: { - id: 1, - title: 'Details', - data: details, - }, - workflowId: childWorkflow?.id, - documents: childWorkflow?.context?.documents, - }) - .build() - .flat(1), + value: getEntityDataBlock(), }) .addCell({ type: 'container', diff --git a/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/create-blocks-typed.ts b/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/create-blocks-typed.ts index 213ee0b93e..956416e8d5 100644 --- a/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/create-blocks-typed.ts +++ b/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/create-blocks-typed.ts @@ -22,6 +22,7 @@ import { Subheading } from '@/lib/blocks/components/Subheading/Subheading'; import { TableCell } from '@/lib/blocks/components/TableCell/TableCell'; import { TCell } from '@/lib/blocks/create-blocks-typed/types'; import { CellsMap, createBlocks } from '@ballerine/blocks'; +import { EditableDetailsV2Cell } from '../components/EditableDetailsV2Cell/EditableDetailsV2Cell'; export const createBlocksTyped = () => createBlocks(); @@ -56,4 +57,5 @@ export const cells: CellsMap = { pdfViewer: PDFViewerCell, readOnlyDetails: ReadOnlyDetailsCell, image: ImageCell, + editableDetails: EditableDetailsV2Cell, }; diff --git a/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts b/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts index a94904702d..79d3b6adfd 100644 --- a/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts +++ b/apps/backoffice-v2/src/lib/blocks/create-blocks-typed/types.ts @@ -24,6 +24,7 @@ import { ComponentProps, ReactNode } from 'react'; import { ReadOnlyDetail } from '@/common/components/atoms/ReadOnlyDetail/ReadOnlyDetail'; import { DataTable } from '@ballerine/ui/dist/components/organisms/DataTable/DataTable'; +import { EditableDetailsV2 } from '@/common/components/organisms/EditableDetailsV2/EditableDetailsV2'; export type TBlockCell = { type: 'block'; @@ -248,6 +249,12 @@ export type TImageCell = { props: Omit, 'src'>; }; +export type TEditableDetailsV2Cell = { + type: 'editableDetails'; + value: ComponentProps['fields']; + props: Omit, 'fields'>; +}; + export type TCell = | TBlockCell | TContainerCell @@ -271,4 +278,5 @@ export type TCell = | TNodeCell | TPDFViewerCell | TReadOnlyDetailsCell - | TImageCell; + | TImageCell + | TEditableDetailsV2Cell; diff --git a/apps/backoffice-v2/src/lib/blocks/utils/sort-data.ts b/apps/backoffice-v2/src/lib/blocks/utils/sort-data.ts index 928bd2dd0b..b550de85b7 100644 --- a/apps/backoffice-v2/src/lib/blocks/utils/sort-data.ts +++ b/apps/backoffice-v2/src/lib/blocks/utils/sort-data.ts @@ -1,19 +1,18 @@ import { SortDirection } from '@ballerine/common'; -import { ExtractCellProps } from '@ballerine/blocks'; -export const sortData = ({ +export const sortData = ({ data, direction = 'asc', predefinedOrder = [], }: { direction?: SortDirection; predefinedOrder?: string[]; - data: ExtractCellProps<'details'>['value']['data']; + data: TObj[]; }) => { - const orderedData = predefinedOrder.map(key => data.find(value => value.title === key)); + const orderedData = predefinedOrder.map(key => data.find(item => item.title === key)); const restData = data - .filter(data => !predefinedOrder.includes(data.title)) + .filter(item => !predefinedOrder.includes(item.title)) .sort((a, b) => direction === 'asc' ? a.title.localeCompare(b.title) : b.title.localeCompare(a.title), ); diff --git a/apps/backoffice-v2/src/pages/Root/Root.page.tsx b/apps/backoffice-v2/src/pages/Root/Root.page.tsx index 1291224bc8..59c0a5063e 100644 --- a/apps/backoffice-v2/src/pages/Root/Root.page.tsx +++ b/apps/backoffice-v2/src/pages/Root/Root.page.tsx @@ -1,13 +1,13 @@ -import React, { FunctionComponent, lazy, useState } from 'react'; -import { Outlet } from 'react-router-dom'; +import { FunctionComponent, lazy, useState } from 'react'; import { Providers } from '../../common/components/templates/Providers/Providers'; -import { ServerDownLayout } from './ServerDown.layout'; import { useCustomerQuery } from '@/domains/customer/hooks/queries/useCustomerQuery/useCustomerQuery'; import { FullScreenLoader } from '@/common/components/molecules/FullScreenLoader/FullScreenLoader'; import Chatbot from '@/domains/chat/chatbot-opengpt'; import { RenderChildrenInIFrame } from '@/common/components/organisms/RenderChildrenInIFrame/RenderChildrenInIFrame'; import { ctw } from '@/common/utils/ctw/ctw'; import { env } from '@/common/env/env'; +import { Outlet } from 'react-router-dom'; +import { ServerDownLayout } from './ServerDown.layout'; const ReactQueryDevtools = lazy(() => process.env.NODE_ENV !== 'production' diff --git a/apps/kyb-app/CHANGELOG.md b/apps/kyb-app/CHANGELOG.md index d383c1dfc0..bd419fe2d1 100644 --- a/apps/kyb-app/CHANGELOG.md +++ b/apps/kyb-app/CHANGELOG.md @@ -1,5 +1,13 @@ # kyb-app +## 0.3.91 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + - @ballerine/workflow-browser-sdk@0.6.75 + ## 0.3.90 ### Patch Changes diff --git a/apps/kyb-app/package.json b/apps/kyb-app/package.json index 9990381d17..15a3923dc6 100644 --- a/apps/kyb-app/package.json +++ b/apps/kyb-app/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/kyb-app", "private": true, - "version": "0.3.90", + "version": "0.3.91", "type": "module", "scripts": { "dev": "vite", @@ -16,9 +16,9 @@ }, "dependencies": { "@ballerine/blocks": "0.2.28", - "@ballerine/common": "^0.9.56", + "@ballerine/common": "^0.9.57", "@ballerine/ui": "0.5.49", - "@ballerine/workflow-browser-sdk": "0.6.74", + "@ballerine/workflow-browser-sdk": "0.6.75", "@lukemorales/query-key-factory": "^1.0.3", "@radix-ui/react-icons": "^1.3.0", "@rjsf/core": "^5.9.0", diff --git a/examples/headless-example/CHANGELOG.md b/examples/headless-example/CHANGELOG.md index 9f85339052..e9ea9663f0 100644 --- a/examples/headless-example/CHANGELOG.md +++ b/examples/headless-example/CHANGELOG.md @@ -1,5 +1,13 @@ # @ballerine/headless-example +## 0.3.74 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + - @ballerine/workflow-browser-sdk@0.6.75 + ## 0.3.73 ### Patch Changes diff --git a/examples/headless-example/package.json b/examples/headless-example/package.json index 045813aa2c..fb203657e0 100644 --- a/examples/headless-example/package.json +++ b/examples/headless-example/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/headless-example", "private": true, - "version": "0.3.73", + "version": "0.3.74", "type": "module", "scripts": { "spellcheck": "cspell \"*\"", @@ -34,8 +34,8 @@ "vite": "^4.5.3" }, "dependencies": { - "@ballerine/common": "0.9.56", - "@ballerine/workflow-browser-sdk": "0.6.74", + "@ballerine/common": "0.9.57", + "@ballerine/workflow-browser-sdk": "0.6.75", "@felte/reporter-svelte": "^1.1.5", "@felte/validator-zod": "^1.0.13", "@fontsource/inter": "^4.5.15", diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index c7758cc567..253268d018 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -1,5 +1,11 @@ # @ballerine/common +## 0.9.57 + +### Patch Changes + +- Updated merchant screening schema + ## 0.9.56 ### Patch Changes diff --git a/packages/common/package.json b/packages/common/package.json index 3208696954..c4212a1d93 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -2,7 +2,7 @@ "private": false, "name": "@ballerine/common", "author": "Ballerine ", - "version": "0.9.56", + "version": "0.9.57", "description": "common", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", diff --git a/packages/common/src/schemas/documents/merchant-screening-plugin-schema.ts b/packages/common/src/schemas/documents/merchant-screening-plugin-schema.ts index 9a6853945f..08782e1260 100644 --- a/packages/common/src/schemas/documents/merchant-screening-plugin-schema.ts +++ b/packages/common/src/schemas/documents/merchant-screening-plugin-schema.ts @@ -113,11 +113,13 @@ const PrincipalSchema = Type.Object({ maxLength: 40, }), Address: AddressSchema, - PhoneNumber: Type.String({ - description: "The principal owner's phone number, including the area code.", - example: '3165557625', - maxLength: 25, - }), + PhoneNumber: Type.Optional( + Type.String({ + description: "The principal owner's phone number, including the area code.", + example: '3165557625', + maxLength: 25, + }), + ), AltPhoneNumber: Type.Optional( Type.String({ description: "The principal owner's alternate phone number, including the area code.", @@ -213,11 +215,13 @@ const MerchantSchema = Type.Object({ }), ), Address: Type.Optional(AddressSchema), - PhoneNumber: Type.String({ - description: "The Business or Merchant's phone number, including the area code.", - example: '3165557625', - maxLength: 25, - }), + PhoneNumber: Type.Optional( + Type.String({ + description: "The Business or Merchant's phone number, including the area code.", + example: '3165557625', + maxLength: 25, + }), + ), AltPhoneNumber: Type.Optional( Type.String({ description: "The Business or Merchant's alternate phone number, including the area code.", diff --git a/packages/common/src/schemas/documents/schemas/entity-schema.ts b/packages/common/src/schemas/documents/schemas/entity-schema.ts index d5b8692fec..14e9150b0f 100644 --- a/packages/common/src/schemas/documents/schemas/entity-schema.ts +++ b/packages/common/src/schemas/documents/schemas/entity-schema.ts @@ -1,84 +1,76 @@ import { Type } from '@sinclair/typebox'; +export const IndividualDataSchema = Type.Object({ + isContactPerson: Type.Optional(Type.Boolean()), + correlationId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + endUserType: Type.Optional(Type.Union([Type.String(), Type.Null()])), + firstName: Type.Optional(Type.String()), + lastName: Type.Optional(Type.String()), + email: Type.Optional(Type.Union([Type.String(), Type.Null()])), + phone: Type.Optional(Type.Union([Type.String(), Type.Null()])), + country: Type.Optional( + Type.Union([Type.String({ description: 'ISO 3166-1 alpha-2 country code' }), Type.Null()]), + ), + dateOfBirth: Type.Optional( + Type.Union([Type.String({ format: 'date' }), Type.String(), Type.Null()]), + ), + avatarUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])), + nationalId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + additionalInfo: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), +}); + +export const BusinessDataSchema = Type.Object({ + correlationId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + businessType: Type.Optional(Type.Union([Type.String(), Type.Null()])), + companyName: Type.String(), + registrationNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), + legalForm: Type.Optional(Type.Union([Type.String(), Type.Null()])), + country: Type.Optional( + Type.Union([Type.String({ description: 'ISO 3166-1 alpha-2 country code' }), Type.Null()]), + ), + countryOfIncorporation: Type.Optional( + Type.Union([Type.String({ description: 'ISO 3166-1 alpha-2 country code' }), Type.Null()]), + ), + dateOfIncorporation: Type.Optional( + Type.Union([Type.String({ format: 'date' }), Type.String(), Type.Null()]), + ), + address: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), + phoneNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), + email: Type.Optional(Type.Union([Type.String(), Type.Null()])), + website: Type.Optional(Type.Union([Type.String(), Type.Null()])), + industry: Type.Optional(Type.Union([Type.String(), Type.Null()])), + taxIdentificationNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), + vatNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), + shareholderStructure: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), + numberOfEmployees: Type.Optional(Type.Number()), + businessPurpose: Type.Optional(Type.Union([Type.String(), Type.Null()])), + avatarUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])), + additionalInfo: Type.Optional( + Type.Union([ + Type.Object( + { + mainRepresentative: Type.Optional( + Type.Object({ + email: Type.Optional(Type.String()), + lastName: Type.Optional(Type.String()), + firstName: Type.Optional(Type.String()), + }), + ), + }, + { additionalProperties: true }, + ), + Type.Null(), + ]), + ), + bankInformation: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), + mccCode: Type.Optional(Type.Number()), + metadata: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), +}); + export const EntitySchema = Type.Object( { type: Type.String({ enum: ['individual', 'business'] }), - data: Type.Union([ - Type.Object({ - isContactPerson: Type.Optional(Type.Boolean()), - correlationId: Type.Optional(Type.Union([Type.String(), Type.Null()])), - endUserType: Type.Optional(Type.Union([Type.String(), Type.Null()])), - firstName: Type.Optional(Type.String()), - lastName: Type.Optional(Type.String()), - email: Type.Optional(Type.Union([Type.String(), Type.Null()])), - phone: Type.Optional(Type.Union([Type.String(), Type.Null()])), - country: Type.Optional( - Type.Union([ - Type.String({ description: 'ISO 3166-1 alpha-2 country code' }), - Type.Null(), - ]), - ), - dateOfBirth: Type.Optional( - Type.Union([Type.String({ format: 'date' }), Type.String(), Type.Null()]), - ), - avatarUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])), - nationalId: Type.Optional(Type.Union([Type.String(), Type.Null()])), - additionalInfo: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), - }), - Type.Object({ - correlationId: Type.Optional(Type.Union([Type.String(), Type.Null()])), - businessType: Type.Optional(Type.Union([Type.String(), Type.Null()])), - companyName: Type.String(), - registrationNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), - legalForm: Type.Optional(Type.Union([Type.String(), Type.Null()])), - country: Type.Optional( - Type.Union([ - Type.String({ description: 'ISO 3166-1 alpha-2 country code' }), - Type.Null(), - ]), - ), - countryOfIncorporation: Type.Optional( - Type.Union([ - Type.String({ description: 'ISO 3166-1 alpha-2 country code' }), - Type.Null(), - ]), - ), - dateOfIncorporation: Type.Optional( - Type.Union([Type.String({ format: 'date' }), Type.String(), Type.Null()]), - ), - address: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), - phoneNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), - email: Type.Optional(Type.Union([Type.String(), Type.Null()])), - website: Type.Optional(Type.Union([Type.String(), Type.Null()])), - industry: Type.Optional(Type.Union([Type.String(), Type.Null()])), - taxIdentificationNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), - vatNumber: Type.Optional(Type.Union([Type.String(), Type.Null()])), - shareholderStructure: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), - numberOfEmployees: Type.Optional(Type.Number()), - businessPurpose: Type.Optional(Type.Union([Type.String(), Type.Null()])), - avatarUrl: Type.Optional(Type.Union([Type.String(), Type.Null()])), - additionalInfo: Type.Optional( - Type.Union([ - Type.Object( - { - mainRepresentative: Type.Optional( - Type.Object({ - email: Type.Optional(Type.String()), - lastName: Type.Optional(Type.String()), - firstName: Type.Optional(Type.String()), - }), - ), - }, - { additionalProperties: true }, - ), - Type.Null(), - ]), - ), - bankInformation: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), - mccCode: Type.Optional(Type.Number()), - metadata: Type.Optional(Type.Union([Type.Object({}), Type.Null()])), - }), - ]), + data: Type.Union([IndividualDataSchema, BusinessDataSchema]), }, { additionalProperties: false }, ); diff --git a/packages/common/src/schemas/index.ts b/packages/common/src/schemas/index.ts index b440e9ceae..324ccf3fac 100644 --- a/packages/common/src/schemas/index.ts +++ b/packages/common/src/schemas/index.ts @@ -19,3 +19,4 @@ export { export { type TAvailableDocuments, type TDocument } from './documents/workflow/documents/types'; export * from './workflow/end-user.schema'; export { WorkflowDefinitionConfigThemeSchema } from './workflow/workflow-config-theme'; +export { BusinessDataSchema, IndividualDataSchema } from './documents/schemas/entity-schema'; diff --git a/packages/workflow-core/CHANGELOG.md b/packages/workflow-core/CHANGELOG.md index bbf88a89ac..ade0f6ba63 100644 --- a/packages/workflow-core/CHANGELOG.md +++ b/packages/workflow-core/CHANGELOG.md @@ -1,5 +1,12 @@ # @ballerine/workflow-core +## 0.6.75 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + ## 0.6.74 ### Patch Changes diff --git a/packages/workflow-core/package.json b/packages/workflow-core/package.json index 4ff2419b4e..806b81f2ba 100644 --- a/packages/workflow-core/package.json +++ b/packages/workflow-core/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflow-core", "author": "Ballerine ", - "version": "0.6.74", + "version": "0.6.75", "description": "workflow-core", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", @@ -31,7 +31,7 @@ "node": ">=12" }, "dependencies": { - "@ballerine/common": "0.9.56", + "@ballerine/common": "0.9.57", "ajv": "^8.12.0", "country-state-city": "^3.1.4", "i18n-iso-countries": "^7.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1474688acc..d4bd7bcc67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ importers: specifier: 0.2.28 version: link:../../packages/blocks '@ballerine/common': - specifier: 0.9.56 + specifier: 0.9.57 version: link:../../packages/common '@ballerine/react-pdf-toolkit': specifier: ^1.2.48 @@ -85,10 +85,10 @@ importers: specifier: ^0.5.48 version: link:../../packages/ui '@ballerine/workflow-browser-sdk': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../sdks/workflow-browser-sdk '@ballerine/workflow-node-sdk': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../sdks/workflow-node-sdk '@botpress/webchat': specifier: ^2.1.10 @@ -270,6 +270,9 @@ importers: libphonenumber-js: specifier: ^1.10.49 version: 1.10.49 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 lowlight: specifier: ^3.1.0 version: 3.1.0 @@ -415,6 +418,9 @@ importers: '@types/leaflet': specifier: ^1.9.3 version: 1.9.8 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/node': specifier: ^18.11.13 version: 18.17.19 @@ -515,13 +521,13 @@ importers: specifier: 0.2.28 version: link:../../packages/blocks '@ballerine/common': - specifier: ^0.9.56 + specifier: ^0.9.57 version: link:../../packages/common '@ballerine/ui': specifier: 0.5.49 version: link:../../packages/ui '@ballerine/workflow-browser-sdk': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../sdks/workflow-browser-sdk '@lukemorales/query-key-factory': specifier: ^1.0.3 @@ -994,10 +1000,10 @@ importers: examples/headless-example: dependencies: '@ballerine/common': - specifier: 0.9.56 + specifier: 0.9.57 version: link:../../packages/common '@ballerine/workflow-browser-sdk': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../sdks/workflow-browser-sdk '@felte/reporter-svelte': specifier: ^1.1.5 @@ -1945,7 +1951,7 @@ importers: packages/workflow-core: dependencies: '@ballerine/common': - specifier: 0.9.56 + specifier: 0.9.57 version: link:../common ajv: specifier: ^8.12.0 @@ -2135,7 +2141,7 @@ importers: sdks/web-ui-sdk: dependencies: '@ballerine/common': - specifier: 0.9.56 + specifier: 0.9.57 version: link:../../packages/common '@zerodevx/svelte-toast': specifier: ^0.8.0 @@ -2262,10 +2268,10 @@ importers: sdks/workflow-browser-sdk: dependencies: '@ballerine/common': - specifier: 0.9.56 + specifier: 0.9.57 version: link:../../packages/common '@ballerine/workflow-core': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../packages/workflow-core xstate: specifier: ^4.37.0 @@ -2404,7 +2410,7 @@ importers: sdks/workflow-node-sdk: dependencies: '@ballerine/workflow-core': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../packages/workflow-core json-logic-js: specifier: ^2.0.2 @@ -2649,13 +2655,13 @@ importers: specifier: 3.347.1 version: 3.347.1 '@ballerine/common': - specifier: 0.9.56 + specifier: 0.9.57 version: link:../../packages/common '@ballerine/workflow-core': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../packages/workflow-core '@ballerine/workflow-node-sdk': - specifier: 0.6.74 + specifier: 0.6.75 version: link:../../sdks/workflow-node-sdk '@faker-js/faker': specifier: ^7.6.0 @@ -2998,7 +3004,7 @@ importers: specifier: ^4.0.0 version: 4.0.0(astro@3.3.3)(tailwindcss@3.3.5)(ts-node@10.9.1) '@ballerine/common': - specifier: ^0.9.56 + specifier: ^0.9.57 version: link:../../packages/common astro: specifier: 3.3.3 @@ -18608,6 +18614,12 @@ packages: resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} dev: false + /@types/lodash-es@4.17.12: + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + dependencies: + '@types/lodash': 4.14.201 + dev: true + /@types/lodash.get@4.4.9: resolution: {integrity: sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==} dependencies: diff --git a/sdks/web-ui-sdk/CHANGELOG.md b/sdks/web-ui-sdk/CHANGELOG.md index ee9a76de65..e24546d7b6 100644 --- a/sdks/web-ui-sdk/CHANGELOG.md +++ b/sdks/web-ui-sdk/CHANGELOG.md @@ -1,5 +1,12 @@ # web-ui-sdk +## 1.5.58 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + ## 1.5.57 ### Patch Changes diff --git a/sdks/web-ui-sdk/package.json b/sdks/web-ui-sdk/package.json index f3e586e95a..ee6cb9bee8 100644 --- a/sdks/web-ui-sdk/package.json +++ b/sdks/web-ui-sdk/package.json @@ -21,7 +21,7 @@ "types": "dist/index.d.ts", "name": "@ballerine/web-ui-sdk", "private": false, - "version": "1.5.57", + "version": "1.5.58", "type": "module", "files": [ "dist" @@ -96,7 +96,7 @@ "vitest": "^0.24.5" }, "dependencies": { - "@ballerine/common": "0.9.56", + "@ballerine/common": "0.9.57", "@zerodevx/svelte-toast": "^0.8.0", "compressorjs": "^1.1.1", "deepmerge": "^4.3.0", diff --git a/sdks/workflow-browser-sdk/CHANGELOG.md b/sdks/workflow-browser-sdk/CHANGELOG.md index 6235469bb2..777b50e446 100644 --- a/sdks/workflow-browser-sdk/CHANGELOG.md +++ b/sdks/workflow-browser-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # @ballerine/workflow-browser-sdk +## 0.6.75 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + - @ballerine/workflow-core@0.6.75 + ## 0.6.74 ### Patch Changes diff --git a/sdks/workflow-browser-sdk/package.json b/sdks/workflow-browser-sdk/package.json index 7bf46873bf..d41f858cc6 100644 --- a/sdks/workflow-browser-sdk/package.json +++ b/sdks/workflow-browser-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflow-browser-sdk", "author": "Ballerine ", - "version": "0.6.74", + "version": "0.6.75", "description": "workflow-browser-sdk", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", @@ -33,8 +33,8 @@ "node": ">=12" }, "dependencies": { - "@ballerine/common": "0.9.56", - "@ballerine/workflow-core": "0.6.74", + "@ballerine/common": "0.9.57", + "@ballerine/workflow-core": "0.6.75", "xstate": "^4.37.0" }, "devDependencies": { diff --git a/sdks/workflow-node-sdk/CHANGELOG.md b/sdks/workflow-node-sdk/CHANGELOG.md index 691f0ef1b4..e677c2aea3 100644 --- a/sdks/workflow-node-sdk/CHANGELOG.md +++ b/sdks/workflow-node-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @ballerine/workflow-node-sdk +## 0.6.75 + +### Patch Changes + +- @ballerine/workflow-core@0.6.75 + ## 0.6.74 ### Patch Changes diff --git a/sdks/workflow-node-sdk/package.json b/sdks/workflow-node-sdk/package.json index 6c034aaeac..16e847112a 100644 --- a/sdks/workflow-node-sdk/package.json +++ b/sdks/workflow-node-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflow-node-sdk", "author": "Ballerine ", - "version": "0.6.74", + "version": "0.6.75", "description": "workflow-node-sdk", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", @@ -28,7 +28,7 @@ "node": ">=12" }, "dependencies": { - "@ballerine/workflow-core": "0.6.74", + "@ballerine/workflow-core": "0.6.75", "json-logic-js": "^2.0.2", "xstate": "^4.36.0" }, diff --git a/services/workflows-service/CHANGELOG.md b/services/workflows-service/CHANGELOG.md index 6f27949869..445d0fe705 100644 --- a/services/workflows-service/CHANGELOG.md +++ b/services/workflows-service/CHANGELOG.md @@ -1,5 +1,14 @@ # @ballerine/workflows-service +## 0.7.79 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.9.57 + - @ballerine/workflow-core@0.6.75 + - @ballerine/workflow-node-sdk@0.6.75 + ## 0.7.78 ### Patch Changes diff --git a/services/workflows-service/package.json b/services/workflows-service/package.json index c139d5c6dc..1e10671c55 100644 --- a/services/workflows-service/package.json +++ b/services/workflows-service/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflows-service", "private": false, - "version": "0.7.78", + "version": "0.7.79", "description": "workflow-service", "scripts": { "spellcheck": "cspell \"*\"", @@ -50,9 +50,9 @@ "@aws-sdk/client-secrets-manager": "^3.620.1", "@aws-sdk/lib-storage": "3.347.1", "@aws-sdk/s3-request-presigner": "3.347.1", - "@ballerine/common": "0.9.56", - "@ballerine/workflow-core": "0.6.74", - "@ballerine/workflow-node-sdk": "0.6.74", + "@ballerine/common": "0.9.57", + "@ballerine/workflow-core": "0.6.75", + "@ballerine/workflow-node-sdk": "0.6.75", "@faker-js/faker": "^7.6.0", "@nestjs/axios": "^2.0.0", "@nestjs/common": "^9.3.12", diff --git a/services/workflows-service/prisma/data-migrations b/services/workflows-service/prisma/data-migrations index bfc772b0ad..0b7e462361 160000 --- a/services/workflows-service/prisma/data-migrations +++ b/services/workflows-service/prisma/data-migrations @@ -1 +1 @@ -Subproject commit bfc772b0ade3ae49465629d6c85ac26aac3796ab +Subproject commit 0b7e462361f048d0c3db72dc1309f421a09c94df diff --git a/services/workflows-service/src/workflow/schemas/zod-schemas.ts b/services/workflows-service/src/workflow/schemas/zod-schemas.ts index dc07bb4f07..a981b6203e 100644 --- a/services/workflows-service/src/workflow/schemas/zod-schemas.ts +++ b/services/workflows-service/src/workflow/schemas/zod-schemas.ts @@ -72,6 +72,15 @@ export const ConfigSchema = z .optional(), }) .optional(), + editableContext: z + .object({ + kyc: z + .object({ + entity: z.boolean().optional(), + }) + .optional(), + }) + .optional(), }) .strict() .optional(); diff --git a/services/workflows-service/src/workflow/workflow.controller.external.ts b/services/workflows-service/src/workflow/workflow.controller.external.ts index c132fa1c1b..d6443937a2 100644 --- a/services/workflows-service/src/workflow/workflow.controller.external.ts +++ b/services/workflows-service/src/workflow/workflow.controller.external.ts @@ -40,11 +40,12 @@ import { WorkflowService } from './workflow.service'; import { Validate } from 'ballerine-nestjs-typebox'; import { PutWorkflowExtensionSchema, WorkflowExtensionSchema } from './schemas/extensions.schemas'; import { type Static, Type } from '@sinclair/typebox'; -import { defaultContextSchema } from '@ballerine/common'; +import { DefaultContextSchema, defaultContextSchema } from '@ballerine/common'; import { WorkflowRunSchema } from './schemas/workflow-run'; import { ValidationError } from '@/errors'; import { WorkflowRuntimeListItemModel } from '@/workflow/workflow-runtime-list-item.model'; import { CreateTokenDto } from '@/workflow/dtos/create-token.dto'; +import { type PartialDeep } from 'type-fest'; export const WORKFLOW_TAG = 'Workflows'; @swagger.ApiBearerAuth() @@ -559,4 +560,47 @@ export class WorkflowControllerExternal { return; } + + @common.Patch('/:workflowRuntimeDataId/sync-entity') + @ApiResponse({ + status: 400, + description: 'Validation error', + schema: Type.Object({ + message: Type.String(), + statusCode: Type.Literal(400), + timestamp: Type.String({ + format: 'date-time', + }), + path: Type.String(), + errors: Type.Array(Type.Object({ message: Type.String(), path: Type.String() })), + }), + }) + @Validate({ + request: [ + { + type: 'param', + name: 'workflowRuntimeDataId', + description: `The id of the workflow runtime data to update`, + schema: Type.String(), + example: '123e4567-e89b-12d3-a456-426614174000', + }, + { + type: 'body', + schema: Type.Any(), + }, + ], + response: Type.Any(), + }) + async updateContextAndSyncEntity( + @common.Param('workflowRuntimeDataId') + workflowRuntimeDataId: string, + @common.Body() body: PartialDeep, + @CurrentProject() projectId: TProjectId, + ) { + return await this.workflowService.updateContextAndSyncEntity({ + workflowRuntimeDataId, + context: body, + projectId, + }); + } } diff --git a/services/workflows-service/src/workflow/workflow.service.ts b/services/workflows-service/src/workflow/workflow.service.ts index 10b0ec69ca..97dec21803 100644 --- a/services/workflows-service/src/workflow/workflow.service.ts +++ b/services/workflows-service/src/workflow/workflow.service.ts @@ -53,10 +53,12 @@ import { import { AnyRecord, buildCollectionFlowState, + BusinessDataSchema, CollectionFlowStatusesEnum, DefaultContextSchema, getDocumentId, getOrderedSteps, + IndividualDataSchema, isErrorWithMessage, isObject, ProcessStatus, @@ -116,6 +118,7 @@ import { addPropertiesSchemaToDocument } from './utils/add-properties-schema-to- import { entitiesUpdate } from './utils/entities-update'; import { WorkflowEventEmitterService } from './workflow-event-emitter.service'; import { WorkflowRuntimeDataRepository } from './workflow-runtime-data.repository'; +import { PartialDeep } from 'type-fest'; type TEntityId = string; @@ -2648,4 +2651,79 @@ export class WorkflowService { }, ); } + + async updateContextAndSyncEntity({ + workflowRuntimeDataId, + context, + projectId, + }: { + workflowRuntimeDataId: string; + context: PartialDeep; + projectId: string; + }) { + await this.prismaService.$transaction(async transaction => { + await this.event( + { + id: workflowRuntimeDataId, + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + payload: { + newContext: context, + arrayMergeOption: ARRAY_MERGE_OPTION.REPLACE, + }, + }, + [projectId], + projectId, + transaction, + ); + + const workflowRuntimeData = await this.workflowRuntimeDataRepository.findById( + workflowRuntimeDataId, + {}, + [projectId], + transaction, + ); + + const endUserContextToEntityAdapter = ({ + firstName, + lastName, + dateOfBirth, + country, + phone, + email, + additionalInfo, + ...rest + }: Static) => + ({ + firstName, + lastName, + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + country, + phone, + email, + additionalInfo: { + ...rest, + ...additionalInfo, + }, + } satisfies Parameters[1]['data']); + + const businessContextToEntityAdapter = (data: Static) => + ({ + companyName: data.companyName, + } satisfies Parameters[1]['data']); + + if (workflowRuntimeData.businessId && context.entity?.data) { + await this.entityRepository.business.updateById(workflowRuntimeData.businessId, { + data: businessContextToEntityAdapter( + context.entity.data as Static, + ), + }); + } + + if (workflowRuntimeData.endUserId && context.entity?.data) { + await this.entityRepository.endUser.updateById(workflowRuntimeData.endUserId, { + data: endUserContextToEntityAdapter(context.entity.data), + }); + } + }); + } } diff --git a/websites/docs/package.json b/websites/docs/package.json index d45e59ac72..ad9c17f937 100644 --- a/websites/docs/package.json +++ b/websites/docs/package.json @@ -17,7 +17,7 @@ "dependencies": { "@astrojs/starlight": "0.11.1", "@astrojs/tailwind": "^4.0.0", - "@ballerine/common": "^0.9.56", + "@ballerine/common": "^0.9.57", "astro": "3.3.3", "sharp": "^0.32.4", "shiki": "^0.14.3"