diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useManageUbosBlock/ubos-form-json-definition.ts b/apps/backoffice-v2/src/lib/blocks/hooks/useManageUbosBlock/ubos-form-json-definition.ts index a34461a3ce..dd0faf3270 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useManageUbosBlock/ubos-form-json-definition.ts +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useManageUbosBlock/ubos-form-json-definition.ts @@ -24,7 +24,7 @@ export const ubosFormJsonDefinition = { ], }, uiSchema: { - titleTemplate: 'text.companyOwnership.contactIndex', + titleTemplate: 'text.companyOwnership.uboIndex', }, }, elements: [ diff --git a/packages/ui/package.json b/packages/ui/package.json index 81f2b6311e..b75c50014f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -73,6 +73,7 @@ "react-json-view": "^1.21.3", "react-phone-input-2": "^2.15.1", "recharts": "^2.7.2", + "sonner": "^1.4.3", "string-ts": "^1.2.0", "tailwind-merge": "^1.10.0", "zod": "^3.23.4" diff --git a/packages/ui/src/common/hooks/useHttp/index.ts b/packages/ui/src/common/hooks/useHttp/index.ts new file mode 100644 index 0000000000..a5a4afd504 --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './useHttp'; +export * from './utils/format-headers'; +export * from './utils/request'; diff --git a/packages/ui/src/common/hooks/useHttp/types.ts b/packages/ui/src/common/hooks/useHttp/types.ts new file mode 100644 index 0000000000..839c5362cb --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/types.ts @@ -0,0 +1,7 @@ +export interface IHttpParams { + url: string; + resultPath: string; + headers?: Record; + method?: 'POST' | 'PUT' | 'GET' | 'DELETE'; + timeout?: number; +} diff --git a/packages/ui/src/common/hooks/useHttp/useHttp.ts b/packages/ui/src/common/hooks/useHttp/useHttp.ts new file mode 100644 index 0000000000..84cd9eacc4 --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/useHttp.ts @@ -0,0 +1,50 @@ +import { AnyObject } from '@/common/types'; +import get from 'lodash/get'; +import { useCallback, useState } from 'react'; +import { IHttpParams } from './types'; +import { request } from './utils/request'; + +export const useHttp = (params: IHttpParams, metadata: AnyObject) => { + const [responseError, setResponseError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const runRequest = useCallback( + async ( + requestPayload?: any, + other?: { + params?: AnyObject; + }, + ) => { + setIsLoading(true); + setResponseError(null); + + try { + const response = await request( + { + ...params, + url: params.url, + }, + metadata, + requestPayload, + other?.params, + ); + + return params.resultPath ? get(response, params.resultPath) : response; + } catch (error) { + console.error(error); + setResponseError(error as Error); + + throw error; + } finally { + setIsLoading(false); + } + }, + [params, metadata], + ); + + return { + isLoading, + error: responseError, + run: runRequest, + }; +}; diff --git a/packages/ui/src/common/hooks/useHttp/useHttp.unit.test.ts b/packages/ui/src/common/hooks/useHttp/useHttp.unit.test.ts new file mode 100644 index 0000000000..926377d1e9 --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/useHttp.unit.test.ts @@ -0,0 +1,149 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useHttp } from './useHttp'; +import { request } from './utils/request'; + +vi.mock('./utils/request', () => ({ + request: vi.fn(), +})); + +describe('useHttp', () => { + const mockParams = { + url: 'test-url', + resultPath: 'data.items', + method: 'GET' as const, + headers: {}, + }; + + const mockMetadata = { + token: 'test-token', + }; + + const mockResponse = { + data: { + items: ['item1', 'item2'], + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return initial state', () => { + const { result } = renderHook(() => useHttp(mockParams, mockMetadata)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(typeof result.current.run).toBe('function'); + }); + + it('should handle successful request', async () => { + vi.mocked(request).mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useHttp(mockParams, mockMetadata)); + + const response = await result.current.run(); + + expect(request).toHaveBeenCalledWith( + { + ...mockParams, + url: mockParams.url, + }, + mockMetadata, + undefined, + undefined, + ); + expect(response).toEqual(['item1', 'item2']); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should handle request with payload', async () => { + vi.mocked(request).mockResolvedValueOnce(mockResponse); + const payload = { test: 'payload' }; + + const { result } = renderHook(() => useHttp(mockParams, mockMetadata)); + + await result.current.run(payload); + + expect(request).toHaveBeenCalledWith( + { + ...mockParams, + url: mockParams.url, + }, + mockMetadata, + payload, + undefined, + ); + }); + + it('should handle request without resultPath', async () => { + const paramsWithoutPath = { + url: 'test-url', + resultPath: '', + method: 'GET' as const, + headers: {}, + }; + vi.mocked(request).mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useHttp(paramsWithoutPath, mockMetadata)); + + const response = await result.current.run(); + + expect(response).toEqual(mockResponse); + }); + + it('should handle error', async () => { + const mockError = new Error('Test error'); + vi.mocked(request).mockRejectedValueOnce(mockError); + + const { result, rerender } = renderHook(() => useHttp(mockParams, mockMetadata)); + + await expect(result.current.run()).rejects.toThrow('Test error'); + + rerender(); + + expect(result.current.error).toBe(mockError); + expect(result.current.isLoading).toBe(false); + }); + + it('should set loading state during request', async () => { + vi.mocked(request).mockImplementationOnce( + () => + new Promise(resolve => { + setTimeout(() => resolve(mockResponse), 100); + }), + ); + + const { result, rerender } = renderHook(() => useHttp(mockParams, mockMetadata)); + + const promise = result.current.run(); + rerender(); + + expect(result.current.isLoading).toBe(true); + + await promise; + + rerender(); + expect(result.current.isLoading).toBe(false); + }); + + it('should handle request with additional params', async () => { + vi.mocked(request).mockResolvedValueOnce(mockResponse); + const additionalParams = { page: 1 }; + + const { result } = renderHook(() => useHttp(mockParams, mockMetadata)); + + await result.current.run(undefined, { params: additionalParams }); + + expect(request).toHaveBeenCalledWith( + { + ...mockParams, + url: mockParams.url, + }, + mockMetadata, + undefined, + additionalParams, + ); + }); +}); diff --git a/packages/ui/src/common/hooks/useHttp/utils/format-headers.ts b/packages/ui/src/common/hooks/useHttp/utils/format-headers.ts new file mode 100644 index 0000000000..6d60a03e9f --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/utils/format-headers.ts @@ -0,0 +1,15 @@ +import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string'; + +export const formatHeaders = ( + headers: Record, + metadata: Record = {}, +) => { + const formattedHeaders: Record = {}; + + Object.entries(headers).forEach(([key, value]) => { + const formattedValue = formatString(value, metadata); + formattedHeaders[key] = formattedValue; + }); + + return formattedHeaders; +}; diff --git a/packages/ui/src/common/hooks/useHttp/utils/format-headers.unit.test.ts b/packages/ui/src/common/hooks/useHttp/utils/format-headers.unit.test.ts new file mode 100644 index 0000000000..7a646e3e86 --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/utils/format-headers.unit.test.ts @@ -0,0 +1,58 @@ +import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string'; +import { describe, expect, it, vi } from 'vitest'; +import { formatHeaders } from './format-headers'; + +vi.mock('@/components/organisms/Form/DynamicForm/utils/format-string', () => ({ + formatString: vi.fn(), +})); + +const mockedFormatString = vi.mocked(formatString); + +describe('formatHeaders', () => { + it('should format headers with metadata', () => { + const headers = { + Authorization: 'Bearer {token}', + 'Content-Type': 'application/json', + }; + + const metadata = { + token: 'abc123', + }; + + mockedFormatString.mockReturnValueOnce('Bearer abc123').mockReturnValueOnce('application/json'); + + const result = formatHeaders(headers, metadata); + + expect(result).toEqual({ + Authorization: 'Bearer abc123', + 'Content-Type': 'application/json', + }); + + expect(mockedFormatString).toHaveBeenCalledTimes(2); + expect(mockedFormatString).toHaveBeenCalledWith('Bearer {token}', metadata); + expect(mockedFormatString).toHaveBeenCalledWith('application/json', metadata); + }); + + it('should handle empty headers', () => { + const result = formatHeaders({}); + + expect(result).toEqual({}); + expect(mockedFormatString).not.toHaveBeenCalled(); + }); + + it('should use empty metadata object if not provided', () => { + const headers = { + 'X-Custom': 'test', + }; + + mockedFormatString.mockReturnValueOnce('test'); + + const result = formatHeaders(headers); + + expect(result).toEqual({ + 'X-Custom': 'test', + }); + + expect(mockedFormatString).toHaveBeenCalledWith('test', {}); + }); +}); diff --git a/packages/ui/src/common/hooks/useHttp/utils/request.ts b/packages/ui/src/common/hooks/useHttp/utils/request.ts new file mode 100644 index 0000000000..ae01eb9a5a --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/utils/request.ts @@ -0,0 +1,38 @@ +import { AnyObject } from '@/common/types'; +import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string'; +import axios from 'axios'; +import { IHttpParams } from '../types'; +import { formatHeaders } from './format-headers'; + +export type TReuqestParams = Omit; + +export const request = async ( + request: TReuqestParams, + metadata: AnyObject = {}, + data?: any, + params?: AnyObject, +) => { + const { url: _url, headers = {}, method, timeout = 5000 } = request; + + const formattedUrl = formatString(_url, { ...metadata, ...params }); + + const formattedHeaders = formatHeaders(headers, metadata); + + try { + const config = { + url: formattedUrl, + method, + headers: formattedHeaders, + data, + timeout, + }; + + const response = await axios(config); + + return response.data; + } catch (error) { + console.error('Failed to perform request.', error); + + throw error; + } +}; diff --git a/packages/ui/src/common/hooks/useHttp/utils/request.unit.test.ts b/packages/ui/src/common/hooks/useHttp/utils/request.unit.test.ts new file mode 100644 index 0000000000..549eedf238 --- /dev/null +++ b/packages/ui/src/common/hooks/useHttp/utils/request.unit.test.ts @@ -0,0 +1,94 @@ +import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string'; +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { formatHeaders } from './format-headers'; +import { request } from './request'; + +vi.mock('axios'); +vi.mock('@/components/organisms/Form/DynamicForm/utils/format-string'); +vi.mock('./format-headers'); + +describe('request', () => { + const mockAxios = vi.mocked(axios); + const mockFormatString = vi.mocked(formatString); + const mockFormatHeaders = vi.mocked(formatHeaders); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should make a request with formatted url and headers', async () => { + const requestParams = { + url: 'http://api.example.com/{path}', + method: 'GET', + headers: { + Authorization: 'Bearer {token}', + }, + } as const; + const metadata = { + path: 'test', + token: '12345', + }; + const mockResponse = { data: { result: 'success' } }; + + mockFormatString.mockReturnValue('http://api.example.com/test'); + mockFormatHeaders.mockReturnValue({ Authorization: 'Bearer 12345' }); + mockAxios.mockResolvedValue(mockResponse); + + const result = await request(requestParams, metadata); + + expect(mockFormatString).toHaveBeenCalledWith('http://api.example.com/{path}', metadata); + expect(mockFormatHeaders).toHaveBeenCalledWith({ Authorization: 'Bearer {token}' }, metadata); + expect(mockAxios).toHaveBeenCalledWith({ + url: 'http://api.example.com/test', + method: 'GET', + headers: { Authorization: 'Bearer 12345' }, + data: undefined, + timeout: 5000, + }); + expect(result).toEqual({ result: 'success' }); + }); + + it('should make a request with data when provided', async () => { + const requestParams = { + url: 'http://api.example.com/test', + method: 'POST', + headers: {}, + } as const; + const data = { foo: 'bar' }; + const mockResponse = { data: { result: 'success' } }; + + mockFormatString.mockReturnValue('http://api.example.com/test'); + mockFormatHeaders.mockReturnValue({}); + mockAxios.mockResolvedValue(mockResponse); + + const result = await request(requestParams, {}, data); + + expect(mockAxios).toHaveBeenCalledWith({ + url: 'http://api.example.com/test', + method: 'POST', + headers: {}, + data: { foo: 'bar' }, + timeout: 5000, + }); + expect(result).toEqual({ result: 'success' }); + }); + + it('should throw and log error when request fails', async () => { + const requestParams = { + url: 'http://api.example.com/test', + method: 'GET', + headers: {}, + } as const; + const error = new Error('Request failed'); + + mockFormatString.mockReturnValue('http://api.example.com/test'); + mockFormatHeaders.mockReturnValue({}); + mockAxios.mockRejectedValue(error); + + const consoleSpy = vi.spyOn(console, 'error'); + + await expect(request(requestParams, {})).rejects.toThrow('Request failed'); + expect(consoleSpy).toHaveBeenCalledWith('Failed to perform request.', error); + }); +}); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/_stories/FileUploadShowcase/FileUploadShowcase.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/_stories/FileUploadShowcase/FileUploadShowcase.tsx index 96caf01727..2097a22809 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/_stories/FileUploadShowcase/FileUploadShowcase.tsx +++ b/packages/ui/src/components/organisms/Form/DynamicForm/_stories/FileUploadShowcase/FileUploadShowcase.tsx @@ -15,6 +15,7 @@ const schema: Array> = [ uploadSettings: { url: 'http://localhost:3000/upload', resultPath: 'filename', + method: 'POST', }, }, }, @@ -28,6 +29,7 @@ const schema: Array> = [ uploadSettings: { url: 'http://localhost:3000/upload-protected', resultPath: 'filename', + method: 'POST', headers: { Authorization: '{token}', }, @@ -45,6 +47,7 @@ const schema: Array> = [ uploadSettings: { url: 'http://localhost:3000/upload', resultPath: 'filename', + method: 'POST', }, template: { id: 'document-1', @@ -63,6 +66,7 @@ const schema: Array> = [ uploadSettings: { url: 'http://localhost:3000/upload', resultPath: 'filename', + method: 'POST', }, template: { id: 'document-2', diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/DocumentField.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/DocumentField.tsx index 9cbf13ed31..38a7aba0a8 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/DocumentField.tsx +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/DocumentField.tsx @@ -1,4 +1,5 @@ -import { ctw } from '@/common'; +import { AnyObject, ctw } from '@/common'; +import { IHttpParams } from '@/common/hooks/useHttp'; import { Button } from '@/components/atoms'; import { Input } from '@/components/atoms/Input'; import { createTestId } from '@/components/organisms/Renderer/utils/create-test-id'; @@ -19,15 +20,29 @@ import { useDocumentUpload } from './hooks/useDocumentUpload'; import { getFileOrFileIdFromDocumentsList } from './hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list'; import { removeDocumentFromListByTemplateId } from './hooks/useDocumentUpload/helpers/remove-document-from-list-by-template-id'; -export interface IDocumentFieldParams< - TTemplate extends { id: string; pages: Array<{ [key: string]: string }> } = { - id: string; - pages: []; - }, -> extends IFileFieldParams { - template: TTemplate; +export interface IDocumentTemplate { + id: string; + category: string; + type: string; + issuer: { + country: string; + }; + version: number; + issuingVersion: number; + properties: AnyObject; + pages: AnyObject[]; +} + +export interface IDocumentFieldParams extends IFileFieldParams { + template: IDocumentTemplate; pageIndex?: number; pageProperty?: string; + documentType: string; + documentVariant: string; + httpParams: { + createDocument: IHttpParams; + deleteDocument: IHttpParams; + }; } export const DOCUMENT_FIELD_TYPE = 'documentfield'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/helpers/build-document-form-data/build-document-form-data.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/helpers/build-document-form-data/build-document-form-data.ts new file mode 100644 index 0000000000..36fe52afb8 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/helpers/build-document-form-data/build-document-form-data.ts @@ -0,0 +1,44 @@ +import { IFormElement } from '../../../../types'; +import { IDocumentFieldParams } from '../../DocumentField'; + +export const buildDocumentFormData = ( + element: IFormElement<'documentfield', IDocumentFieldParams>, + { entityId, businessId }: { entityId?: string; businessId?: string }, + file: File, +) => { + if (!element.params) { + throw new Error('Document field params are required'); + } + + const { template, documentType, documentVariant, pageIndex = 0 } = element.params; + + const payload = new FormData(); + + payload.append('category', template?.category as string); + payload.append('type', template?.type as string); + payload.append('issuingVersion', template?.issuingVersion as unknown as string); + payload.append('version', template?.version as unknown as string); + payload.append('status', 'provided'); + payload.append('properties', JSON.stringify(template.properties || {})); + payload.append('issuingCountry', template?.issuer?.country as string); + + if (entityId) { + payload.append('endUserId', entityId); + } + + if (businessId) { + payload.append('businessId', businessId); + } + + payload.append('file', file as File, file.name); + payload.append( + 'metadata', + JSON.stringify({ + type: documentType, + variant: documentVariant, + page: pageIndex + 1, + }), + ); + + return payload; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/helpers/build-document-form-data/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/helpers/build-document-form-data/index.ts new file mode 100644 index 0000000000..a0df59ee68 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/helpers/build-document-form-data/index.ts @@ -0,0 +1 @@ +export * from './build-document-form-data'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents/create-or-update-fileid-or-file-in-documents.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents/create-or-update-fileid-or-file-in-documents.unit.test.ts index c5d869e5d9..f900274a26 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents/create-or-update-fileid-or-file-in-documents.unit.test.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents/create-or-update-fileid-or-file-in-documents.unit.test.ts @@ -18,7 +18,7 @@ describe('createOrUpdateFileIdOrFileInDocuments', () => { template: mockTemplate, pageIndex: 0, pageProperty: 'ballerineFileId', - }, + } as unknown as IDocumentFieldParams, }; it('should create new document when documents array is empty', () => { @@ -104,7 +104,7 @@ describe('createOrUpdateFileIdOrFileInDocuments', () => { params: { template: mockTemplate, }, - }; + } as unknown as IFormElement<'documentfield', IDocumentFieldParams>; const result = createOrUpdateFileIdOrFileInDocuments( [], diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.ts index 3a28766d41..869bf7d38d 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.ts @@ -11,9 +11,12 @@ export const getFileOrFileIdFromDocumentsList = ( const documentIndex = documentsList?.findIndex(document => document.id === template?.id); - if (documentIndex === -1) return undefined; + if (documentIndex === -1) { + return undefined; + } const filePath = composePathToFileId(documentIndex, pageProperty, pageIndex); + console.log('filePath', filePath); const fileOrFileId = get(documentsList, filePath, undefined); return fileOrFileId; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.unit.test.ts index dc12c111c2..230c8babd7 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.unit.test.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list/get-file-or-fileid-from-documents-list.unit.test.ts @@ -1,6 +1,6 @@ import { IFormElement } from '@/components/organisms/Form/DynamicForm/types'; import { describe, expect, it } from 'vitest'; -import { IDocumentFieldParams } from '../../../../DocumentField'; +import { IDocumentFieldParams, IDocumentTemplate } from '../../../../DocumentField'; import { getFileOrFileIdFromDocumentsList } from './get-file-or-fileid-from-documents-list'; describe('getFileOrFileIdFromDocumentsList', () => { @@ -11,11 +11,32 @@ describe('getFileOrFileIdFromDocumentsList', () => { params: { template: { id: 'doc-1', - pages: [], + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: { + pages: [], + }, }, pageIndex: 0, pageProperty: 'ballerineFileId', - }, + documentType: 'test', + documentVariant: 'test', + httpParams: { + createDocument: { + url: '', + resultPath: '', + }, + deleteDocument: { + url: '', + resultPath: '', + }, + }, + } as unknown as IDocumentFieldParams, }; it('should return undefined when documentsList is empty', () => { @@ -24,27 +45,43 @@ describe('getFileOrFileIdFromDocumentsList', () => { }); it('should return undefined when document with matching template id is not found', () => { - const documentsList = [ + const documentsList: IDocumentTemplate[] = [ { id: 'different-doc', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, pages: [], + issuingVersion: 1, + properties: {}, }, - ] as Array; + ]; const result = getFileOrFileIdFromDocumentsList(documentsList, mockElement); expect(result).toBeUndefined(); }); it('should return file id when matching document is found', () => { - const documentsList = [ + const documentsList: IDocumentTemplate[] = [ { id: 'doc-1', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, pages: [ { ballerineFileId: 'file-123', }, ], + properties: {}, }, - ] as unknown as Array; + ]; const result = getFileOrFileIdFromDocumentsList(documentsList, mockElement); expect(result).toBe('file-123'); }); @@ -54,18 +91,33 @@ describe('getFileOrFileIdFromDocumentsList', () => { id: 'test-doc', element: 'documentfield', valueDestination: 'documents', - }; + params: { + template: { + id: 'doc-1', + category: 'test', + type: 'test', + } as IDocumentTemplate, + }, + } as unknown as IFormElement<'documentfield', IDocumentFieldParams>; - const documentsList = [ + const documentsList: IDocumentTemplate[] = [ { - id: undefined, + id: 'doc-1', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, pages: [ { ballerineFileId: 'file-123', }, ], }, - ] as unknown as Array; + ]; const result = getFileOrFileIdFromDocumentsList(documentsList, elementWithoutParams); expect(result).toBe('file-123'); @@ -80,17 +132,34 @@ describe('getFileOrFileIdFromDocumentsList', () => { pageIndex: 1, template: { id: 'doc-1', - pages: [{ customFileId: 'file-1' }, { customFileId: 'file-2' }], + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: { + pages: [{ customFileId: 'file-1' }, { customFileId: 'file-2' }], + }, }, }, } as unknown as IFormElement<'documentfield', IDocumentFieldParams>; - const documentsList = [ + const documentsList: IDocumentTemplate[] = [ { id: 'doc-1', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, pages: [{ customFileId: 'file-1' }, { customFileId: 'file-2' }], }, - ] as unknown as Array; + ]; const result = getFileOrFileIdFromDocumentsList(documentsList, customElement); expect(result).toBe('file-2'); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/remove-document-from-list-by-template-id/remove-document-from-list-by-template-id.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/remove-document-from-list-by-template-id/remove-document-from-list-by-template-id.unit.test.ts index 590de6eb32..acde1cb632 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/remove-document-from-list-by-template-id/remove-document-from-list-by-template-id.unit.test.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/helpers/remove-document-from-list-by-template-id/remove-document-from-list-by-template-id.unit.test.ts @@ -1,13 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { IDocumentFieldParams } from '../../../..'; +import { IDocumentTemplate } from '../../../..'; import { removeDocumentFromListByTemplateId } from './remove-document-from-list-by-template-id'; describe('removeDocumentFromListByTemplateId', () => { it('should remove document with matching template id from list', () => { const documents = [ - { id: 'doc1', pages: [] }, - { id: 'doc2', pages: [] }, - ] as Array; + { + id: 'doc1', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + { + id: 'doc2', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + ] as IDocumentTemplate[]; const result = removeDocumentFromListByTemplateId(documents, 'doc1'); @@ -17,9 +37,29 @@ describe('removeDocumentFromListByTemplateId', () => { it('should return original list if template id not found', () => { const documents = [ - { id: 'doc1', pages: [] }, - { id: 'doc2', pages: [] }, - ] as Array; + { + id: 'doc1', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + { + id: 'doc2', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + ] as IDocumentTemplate[]; const result = removeDocumentFromListByTemplateId(documents, 'doc3'); @@ -41,10 +81,40 @@ describe('removeDocumentFromListByTemplateId', () => { it('should remove only matching document when multiple documents exist', () => { const documents = [ - { id: 'doc1', pages: [] }, - { id: 'doc2', pages: [] }, - { id: 'doc3', pages: [] }, - ] as Array; + { + id: 'doc1', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + { + id: 'doc2', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + { + id: 'doc3', + category: 'test', + type: 'test', + issuer: { + country: 'test', + }, + version: 1, + issuingVersion: 1, + properties: {}, + }, + ] as IDocumentTemplate[]; const result = removeDocumentFromListByTemplateId(documents, 'doc2'); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/useDocumentUpload.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/useDocumentUpload.ts index 141555906c..bf30dd130e 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/useDocumentUpload.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/DocumentField/hooks/useDocumentUpload/useDocumentUpload.ts @@ -16,7 +16,7 @@ import { createOrUpdateFileIdOrFileInDocuments } from './helpers/create-or-updat export const useDocumentUpload = ( element: IFormElement<'documentfield', IDocumentFieldParams>, - params: IDocumentFieldParams, + params: IDocumentFieldParams, ) => { const { uploadOn = 'change' } = params; const { stack } = useStack(); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/EntityFieldGroup.stories.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/EntityFieldGroup.stories.tsx new file mode 100644 index 0000000000..8cf4bda97b --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/EntityFieldGroup.stories.tsx @@ -0,0 +1,638 @@ +import { AnyObject } from '@/common'; +import { useState } from 'react'; +import { JSONEditorComponent } from '../../../Validator/_stories/components/JsonEditor/JsonEditor'; +import { DynamicFormV2 } from '../../DynamicForm'; +import { IFormElement } from '../../types'; + +const initialContext = { + firstName: 'John', + lastName: 'Doe', +}; + +const defaultSchema: Array> = [ + { + id: 'directors', + element: 'entityfieldgroup', + valueDestination: 'users', + params: { + label: 'Field List', + description: 'A list of repeatable form fields that can be added or removed', + defaultValue: `{ + "firstName": firstName, + "lastName": lastName + }`, + type: 'director', + }, + children: [ + { + id: 'user-name', + element: 'textfield', + valueDestination: 'users[$0].firstName', + params: { + label: 'Text Field', + placeholder: 'Enter text', + description: 'Enter text for this list item', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Name is required', + }, + ], + }, + { + id: 'user-lastname', + element: 'textfield', + valueDestination: 'users[$0].lastName', + params: { + label: 'Last Name', + placeholder: 'Enter last name', + description: 'Enter your last name', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Last name is required', + }, + ], + }, + { + id: 'document', + element: 'documentfield', + valueDestination: 'users[$0].documents', + params: { + label: 'Document', + template: { + id: 'document', + }, + }, + validate: [ + { + type: 'document', + value: { + id: 'document', + }, + message: 'Document is required', + considerRequired: true, + }, + ], + }, + ], + }, + { + id: 'SubmitButton', + element: 'submitbutton', + valueDestination: 'submitbutton', + params: { + label: 'Submit Button', + }, + }, +]; + +export const EntityFieldGroup = () => { + const [context, setContext] = useState(initialContext); + + return ( +
+
+ { + console.log('onSubmit'); + }} + onChange={setContext} + // onEvent={console.log} + /> +
+
+ +
+
+ ); +}; + +export default { + component: EntityFieldGroup, +}; + +export const Default = { + render: () => , +}; + +const ubosSchema: Array> = [ + { + id: 'ubos', + element: 'entityfieldgroup', + valueDestination: 'entity.data.additionalInfo.ubos', + params: { + label: 'Field List', + description: 'A list of repeatable form fields that can be added or removed', + defaultValue: `{ + "firstName": firstName, + "lastName": lastName + }`, + type: 'ubo', + httpParams: { + createEntity: { + httpParams: { + url: '{apiUrl}collection-flow/entity', + method: 'POST', + headers: { + Authorization: 'Bearer {token}', + }, + resultPath: 'entityId', + }, + transform: `{ + "firstName": entity.firstName, + "lastName": entity.lastName, + "email": entity.email, + "phone": entity.phone, + "country": entity.country, + "dateOfBirth": entity.dateOfBirth, + "nationality": entity.nationality, + "passportNumber": entity.passportNumber, + "address": entity.street & ", " & entity.city & ", " & entity.country, + "nationalId": entity.nationalId, + "isAuthorizedSignatory": entity.isAuthorizedSignatory, + "city": entity.city, + "additionalInfo": { + "fullAddress": entity.street & ", " & entity.city & ", " & entity.country, + "companyName": context.entity.data.companyName, + "customerCompany": context.collectionFlow.additionalInformation.customerCompany, + "placeOfBirth": entity.placeOfBirth, + "percentageOfOwnership": entity.ownershipPercentage, + "role": entity.role + } + }`, + }, + deleteEntity: { + url: '{apiUrl}collection-flow/entity/{entityId}', + method: 'DELETE', + headers: { + Authorization: 'Bearer {token}', + }, + }, + updateEntity: { + httpParams: { + url: '{apiUrl}collection-flow/entity/{entityId}', + method: 'PUT', + headers: { + Authorization: 'Bearer {token}', + }, + }, + transform: `{ + "firstName": entity.firstName, + "lastName": entity.lastName, + "email": entity.email, + "phone": entity.phone, + "country": entity.country, + "dateOfBirth": entity.dateOfBirth, + "nationality": entity.nationality, + "passportNumber": entity.passportNumber, + "address": entity.street & ", " & entity.city & ", " & entity.country, + "nationalId": entity.nationalId, + "isAuthorizedSignatory": entity.isAuthorizedSignatory, + "city": entity.city, + "additionalInfo": { + "fullAddress": entity.street & ", " & entity.city & ", " & entity.country, + "companyName": context.entity.data.companyName, + "customerCompany": context.collectionFlow.additionalInformation.customerCompany, + "placeOfBirth": entity.placeOfBirth, + "percentageOfOwnership": entity.ownershipPercentage, + "role": entity.role + } + }`, + }, + uploadDocument: { + url: '{apiUrl}collection-flow/files', + method: 'POST', + headers: { + Authorization: 'Bearer {token}', + }, + resultPath: 'id', + }, + deleteDocument: { + url: '{apiUrl}collection-flow/files/{documentId}', + method: 'DELETE', + headers: { + Authorization: 'Bearer {token}', + }, + }, + }, + }, + children: [ + { + id: 'user-name', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].firstName', + params: { + label: 'Text Field', + placeholder: 'Enter text', + description: 'Enter text for this list item', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Name is required', + }, + ], + }, + { + id: 'user-lastname', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].lastName', + params: { + label: 'Last Name', + placeholder: 'Enter last name', + description: 'Enter your last name', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Last name is required', + }, + ], + }, + { + id: 'user-email', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].email', + params: { + label: 'Email', + placeholder: 'Enter email', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Email is required', + }, + { + type: 'format', + value: { + format: 'email', + }, + message: 'Invalid email', + }, + ], + }, + { + id: 'percentage-of-ownership', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].percentageOfOwnership', + params: { + label: 'Percentage of Ownership', + placeholder: 'Enter percentage of ownership', + valueType: 'number', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Percentage of ownership is required', + }, + { + type: 'minimum', + value: { + minimum: 0, + }, + message: 'Percentage of ownership must be greater than 0', + }, + { + type: 'maximum', + value: { + maximum: 100, + }, + message: 'Percentage of ownership must be less than 100', + }, + ], + }, + { + id: 'role', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].role', + params: { + label: 'Role', + placeholder: 'Enter role', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Role is required', + }, + ], + }, + { + id: 'phone-number', + element: 'phonefield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].phone', + params: { + label: 'Phone Number', + placeholder: 'Enter phone number', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Phone number is required', + }, + ], + }, + { + id: 'passport-number', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].passportNumber', + params: { + label: 'Passport Number', + placeholder: 'Enter passport number', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Passport number is required', + }, + ], + }, + { + id: 'date-of-birth', + element: 'datefield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].dateOfBirth', + params: { + label: 'Date of Birth', + placeholder: 'Enter date of birth', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Date of birth is required', + }, + ], + }, + { + id: 'place-of-birth', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].placeOfBirth', + params: { + label: 'Place of Birth', + placeholder: 'Enter place of birth', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Place of birth is required', + }, + ], + }, + { + id: 'country', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].country', + params: { + label: 'Country', + placeholder: 'Enter country', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Country is required', + }, + ], + }, + { + id: 'street', + element: 'textfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].street', + params: { + label: 'Street', + placeholder: 'Enter street', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Street is required', + }, + ], + }, + { + id: 'document', + element: 'documentfield', + valueDestination: 'entity.data.additionalInfo.ubos[$0].documents', + params: { + label: 'Document', + template: { + id: 'document', + category: 'proof_of_address', + type: 'general_document', + issuingVersion: 1, + version: 1, + issuer: { + country: 'ZZ', + }, + properties: {}, + }, + documentType: 'document', + documentVariant: 'front', + httpParams: { + deleteDocument: { + url: '{apiUrl}collection-flow/files', + method: 'DELETE', + headers: { + Authorization: 'Bearer {token}', + }, + }, + }, + }, + }, + ], + validate: [ + { + type: 'required', + value: {}, + message: 'At least one UBO is required', + }, + ], + }, + { + id: 'SubmitButton', + element: 'submitbutton', + valueDestination: 'submitbutton', + params: { + label: 'Submit Button', + }, + disable: [], + }, +]; + +const initialUbosContext = { + entity: { + data: { + companyName: 'Company Name', + }, + }, + collectionFlow: { + additionalInformation: { + customerCompany: 'Customer Company', + }, + }, +}; + +const metadata = { + apiUrl: 'http://localhost:3000/api/v1/', + token: 'e3a69aa3-c1ad-42f3-87ac-5105cff81a94', + workflowId: 'cm6ufmpme0004tl7sqoxwlah4', +}; + +export const UbosFieldGroup = () => { + const [context, setContext] = useState(initialUbosContext); + + return ( +
+
+ { + console.log('onSubmit'); + }} + onChange={setContext} + metadata={metadata} + validationParams={{ + validateOnChange: true, + validateOnBlur: true, + abortEarly: false, + abortAfterFirstError: true, + validationDelay: 300, + }} + // onEvent={console.log} + /> +
+
+ +
+
+ ); +}; + +export const Ubos = { + render: () => , +}; + +const directorsSchema: Array> = [ + { + id: 'directors', + element: 'entityfieldgroup', + valueDestination: 'users', + params: { + label: 'Field List', + description: 'A list of repeatable form fields that can be added or removed', + defaultValue: `{ + "firstName": firstName, + "lastName": lastName + }`, + type: 'director', + }, + children: [ + { + id: 'user-name', + element: 'textfield', + valueDestination: 'users[$0].firstName', + params: { + label: 'Text Field', + placeholder: 'Enter text', + description: 'Enter text for this list item', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Name is required', + }, + ], + }, + { + id: 'user-lastname', + element: 'textfield', + valueDestination: 'users[$0].lastName', + params: { + label: 'Last Name', + placeholder: 'Enter last name', + description: 'Enter your last name', + }, + validate: [ + { + type: 'required', + value: {}, + message: 'Last name is required', + }, + ], + }, + { + id: 'document', + element: 'documentfield', + valueDestination: 'users[$0].documents', + params: { + label: 'Document', + template: { + id: 'document', + }, + }, + validate: [ + { + type: 'document', + value: { + id: 'document', + }, + message: 'Document is required', + considerRequired: true, + }, + ], + }, + ], + }, + { + id: 'SubmitButton', + element: 'submitbutton', + valueDestination: 'submitbutton', + params: { + label: 'Submit Button', + }, + }, +]; + +export const DirectorsFieldGroup = () => { + const [context, setContext] = useState(initialContext); + + return ( +
+
+ { + console.log('onSubmit'); + }} + onChange={setContext} + // onEvent={console.log} + /> +
+
+ +
+
+ ); +}; + +export const Directors = { + render: () => , +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/EntityFieldGroup.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/EntityFieldGroup.tsx new file mode 100644 index 0000000000..a10a820e97 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/EntityFieldGroup.tsx @@ -0,0 +1,110 @@ +import { AnyObject } from '@/common'; +import { IHttpParams } from '@/common/hooks/useHttp'; +import { Button } from '@/components/atoms'; +import { useMemo } from 'react'; +import { Toaster } from 'sonner'; +import { useDynamicForm } from '../../context'; +import { useElement, useField } from '../../hooks/external'; +import { useMountEvent } from '../../hooks/internal/useMountEvent'; +import { useUnmountEvent } from '../../hooks/internal/useUnmountEvent'; +import { FieldDescription } from '../../layouts/FieldDescription'; +import { FieldErrors } from '../../layouts/FieldErrors'; +import { FieldPriorityReason } from '../../layouts/FieldPriorityReason'; +import { TDynamicFormField } from '../../types'; +import { IFieldListParams, useStack } from '../FieldList'; +import { EntityFieldGroupDocument } from './components/EntityFieldGroupDocument'; +import { EntityFields } from './components/EntityFields'; +import { getEntityGroupValueDestination } from './helpers/get-entity-group-value-destination'; +import { useEntityFieldGroupList } from './hooks/useEntityFieldGroupList'; +import { IEntity } from './types'; + +export type TEntityFieldGroupType = 'director' | 'ubo'; + +export interface ICreateEntityParams { + httpParams: IHttpParams; + transform?: string; +} + +export interface IUpdateEntityParams { + httpParams: IHttpParams; + transform?: string; +} + +export interface IEntityFieldGroupParams extends IFieldListParams { + httpParams: { + createEntity: ICreateEntityParams; + deleteEntity: IHttpParams; + uploadDocument: IHttpParams; + updateEntity: IUpdateEntityParams; + deleteDocument: IHttpParams; + }; + createEntityText?: string; + type: TEntityFieldGroupType; +} + +export const EntityFieldGroup: TDynamicFormField = ({ + element: _element, +}) => { + const element = useMemo( + () => ({ + ..._element, + valueDestination: getEntityGroupValueDestination( + _element.params?.type as TEntityFieldGroupType, + ), + }), + [_element], + ); + + useMountEvent(element); + useUnmountEvent(element); + + const { elementsMap } = useDynamicForm(); + const { stack } = useStack(); + const { id: fieldId, hidden } = useElement(element, stack); + const { disabled, onChange } = useField(element, stack); + const { addButtonLabel = 'Add Item' } = element.params || {}; + const { items, isRemovingEntity, addItem, removeItem } = useEntityFieldGroupList({ element }); + + const elementsOverride = useMemo( + () => ({ + ...elementsMap, + documentfield: EntityFieldGroupDocument, + }), + [elementsMap], + ); + + if (hidden) { + return null; + } + + return ( +
+ {items?.map((entity: IEntity, index: number) => { + return ( + removeItem(entity.__id!)} + stack={stack} + fieldId={fieldId} + element={element} + elementsOverride={elementsOverride as AnyObject} + isRemovingEntity={isRemovingEntity} + onChange={onChange} + /> + ); + })} +
+ +
+ + + + +
+ ); +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/EntityFieldGroupDocument.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/EntityFieldGroupDocument.tsx new file mode 100644 index 0000000000..2d87c2a4b4 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/EntityFieldGroupDocument.tsx @@ -0,0 +1,195 @@ +import { ctw } from '@/common'; +import { IHttpParams, useHttp } from '@/common/hooks/useHttp'; +import { Button } from '@/components/atoms'; +import { Input } from '@/components/atoms/Input'; +import { createTestId } from '@/components/organisms/Renderer/utils/create-test-id'; +import get from 'lodash/get'; +import set from 'lodash/set'; +import { Upload, XCircle } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useDynamicForm } from '../../../../context'; +import { useField } from '../../../../hooks/external'; +import { useMountEvent } from '../../../../hooks/internal/useMountEvent'; +import { useUnmountEvent } from '../../../../hooks/internal/useUnmountEvent'; +import { FieldDescription } from '../../../../layouts/FieldDescription'; +import { FieldErrors } from '../../../../layouts/FieldErrors'; +import { FieldLayout } from '../../../../layouts/FieldLayout'; +import { FieldPriorityReason } from '../../../../layouts/FieldPriorityReason'; +import { IFormElement, TDynamicFormElement } from '../../../../types'; +import { IDocumentFieldParams } from '../../../DocumentField'; +import { createOrUpdateFileIdOrFileInDocuments } from '../../../DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents'; +import { getFileOrFileIdFromDocumentsList } from '../../../DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list'; +import { removeDocumentFromListByTemplateId } from '../../../DocumentField/hooks/useDocumentUpload/helpers/remove-document-from-list-by-template-id'; +import { useStack } from '../../../FieldList'; +import { TEntityFieldGroupType } from '../../EntityFieldGroup'; +import { useEntityField } from '../../providers/EntityFieldProvider'; +import { getEntityFieldGroupDocumentValueDestination } from './helpers/get-entity-field-group-document-value-destination'; + +export interface IEntityFieldGroupDocumentParams extends IDocumentFieldParams { + type: TEntityFieldGroupType; +} + +export const EntityFieldGroupDocument: TDynamicFormElement< + 'documentfield', + IEntityFieldGroupDocumentParams +> = ({ element: _element }) => { + const { metadata, values, fieldHelpers } = useDynamicForm(); + const { entityFieldGroupType, isSyncing } = useEntityField(); + + const valuesRef = useRef(values); + + useEffect(() => { + valuesRef.current = values; + }, [values]); + + const element = useMemo( + () => ({ + ..._element, + valueDestination: getEntityFieldGroupDocumentValueDestination( + entityFieldGroupType || (_element.params?.type as TEntityFieldGroupType), + ), + }), + [_element, entityFieldGroupType], + ); + + const { run: deleteDocument, isLoading: isDeletingDocument } = useHttp( + (element.params?.httpParams?.deleteDocument as IHttpParams) || {}, + metadata, + ); + + useMountEvent(element); + useUnmountEvent(element); + + const { params } = element; + const { placeholder = 'Choose file', acceptFileFormats = undefined } = params || {}; + + const { stack } = useStack(); + const { + value: documentsList, + disabled, + onChange, + onBlur, + onFocus, + } = useField | undefined>(element, stack); + const value = useMemo( + () => + getFileOrFileIdFromDocumentsList( + documentsList, + element as IFormElement<'documentfield', IDocumentFieldParams>, + ), + [documentsList, element], + ); + + const file = useMemo(() => { + if (value instanceof File) { + return value; + } + + if (typeof value === 'string') { + return new File([], value); + } + + return undefined; + }, [value]); + + const inputRef = useRef(null); + const focusInputOnContainerClick = useCallback(() => { + inputRef.current?.click(); + }, [inputRef]); + + const clearFileAndInput = useCallback(async () => { + if (!element.params?.template?.id) { + console.warn('Template id is migging in element', element); + + return; + } + + const fileIdOrFile = getFileOrFileIdFromDocumentsList(documentsList, element); + + if (typeof fileIdOrFile === 'string') { + await deleteDocument({ + ids: [fileIdOrFile], + }); + } + + const updatedDocuments = removeDocumentFromListByTemplateId( + documentsList, + element.params?.template?.id as string, + ); + + onChange(updatedDocuments); + + if (inputRef.current) { + inputRef.current.value = ''; + } + }, [documentsList, element, deleteDocument, onChange]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const documents = get(documentsList || [], element.valueDestination); + const updatedDocuments = createOrUpdateFileIdOrFileInDocuments( + documents, + element, + e.target.files?.[0] as File, + ); + + set(valuesRef.current, element.valueDestination, updatedDocuments); + + fieldHelpers.setValues(structuredClone(valuesRef.current)); + + onChange(updatedDocuments); + }, + [onChange, fieldHelpers, valuesRef, element, documentsList], + ); + + return ( + +
+
+ + {placeholder} +
+ {file ? file.name : 'No File Choosen'} + {file && ( + + )} + +
+ + + +
+ ); +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/helpers/get-entity-field-group-document-value-destination.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/helpers/get-entity-field-group-document-value-destination.ts new file mode 100644 index 0000000000..c375ba475e --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/helpers/get-entity-field-group-document-value-destination.ts @@ -0,0 +1,14 @@ +import { TEntityFieldGroupType } from '../../../EntityFieldGroup'; + +export const getEntityFieldGroupDocumentValueDestination = (type: TEntityFieldGroupType) => { + const valueDestinationsMap: Record = { + director: 'entity.data.additionalInfo.directors[$0].additionalInfo.documents', + ubo: 'entity.data.additionalInfo.ubos[$0].documents', + }; + + if (!valueDestinationsMap[type]) { + throw new Error(`Invalid entity field group type in documentfield: ${type}`); + } + + return valueDestinationsMap[type]; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/index.ts new file mode 100644 index 0000000000..c41385cc7a --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFieldGroupDocument/index.ts @@ -0,0 +1 @@ +export * from './EntityFieldGroupDocument'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/EntityFields.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/EntityFields.tsx new file mode 100644 index 0000000000..5ad99e5e5b --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/EntityFields.tsx @@ -0,0 +1,172 @@ +import { useHttp } from '@/common/hooks/useHttp'; +import { Button } from '@/components/atoms'; +import { formatValueDestination, TDeepthLevelStack } from '@/components/organisms/Form/Validator'; +import { Renderer, TRendererSchema } from '@/components/organisms/Renderer'; +import get from 'lodash/get'; +import set from 'lodash/set'; +import { Loader2, Trash2Icon } from 'lucide-react'; +import { FunctionComponent, useCallback, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { useDynamicForm } from '../../../../context'; +import { IFormElement } from '../../../../types'; +import { createOrUpdateFileIdOrFileInDocuments } from '../../../DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents'; +import { StackProvider } from '../../../FieldList/providers/StackProvider'; +import { IEntityFieldGroupParams } from '../../EntityFieldGroup'; +import { useEntitySync } from '../../hooks/useEntitySync'; +import { EntityFieldProvider } from '../../providers/EntityFieldProvider'; +import { IEntity } from '../../types'; +import { buildDocumentsCreationPayload } from './helpers/build-documents-creation-payload'; +import { buildEntityCreationPayload } from './helpers/build-entity-for-creation'; +import { updateEntities } from './helpers/update-entities'; +import { useChildrenDisabledOnLock } from './hooks/useChildrenDisabledOnLock'; +import { useEntityFieldsIsValid } from './hooks/useIsEntityFieldsValid'; + +interface IEntityFieldsProps { + stack: TDeepthLevelStack; + fieldId: string; + entityId: string; + entity: IEntity; + element: IFormElement; + elementsOverride: TRendererSchema; + isRemovingEntity?: boolean; + index: number; + onRemoveClick: () => void; + onChange: (entities: IEntity[]) => void; +} + +export const EntityFields: FunctionComponent = ({ + stack, + fieldId, + entityId, + element, + entity, + elementsOverride, + isRemovingEntity, + index, + onRemoveClick, + onChange, +}) => { + const { metadata, values } = useDynamicForm(); + const [isCreatingEntity, setIsCreatingEntity] = useState(false); + + const { run: createEntity } = useHttp( + element.params!.httpParams?.createEntity.httpParams, + metadata, + ); + const { run: uploadDocument } = useHttp(element.params!.httpParams?.uploadDocument, metadata); + + const { createEntityText = 'Create' } = element.params || {}; + + const isValid = useEntityFieldsIsValid(element, index); + const { isSyncing } = useEntitySync(element, entity, stack, isValid); + + const createEntityAndUploadDocuments = useCallback(async () => { + setIsCreatingEntity(true); + + const context = values; + + const entitiesDestination = formatValueDestination(element.valueDestination, stack); + + const createEntityPayload = await buildEntityCreationPayload(element, entity, context); + + let createdEntityId: string; + + try { + createdEntityId = await createEntity(createEntityPayload); + } catch (error) { + console.error(error); + toast.error('Failed to create entity.'); + setIsCreatingEntity(false); + throw error; + } + + const entities = get(context, entitiesDestination, []); + const createdEntity = { ...entity, id: createdEntityId }; + + // UI Update + const updatedEntities = updateEntities(entities, createdEntity); + set(context, entitiesDestination, updatedEntities); + + const documentsCreationPayload = await buildDocumentsCreationPayload(element, context, { + entityId: createdEntityId, + stack: stack, + }); + + const documentUploadPromises = documentsCreationPayload.map(async document => { + const documentId = await uploadDocument(document.payload); + + const updatedDocuments = createOrUpdateFileIdOrFileInDocuments( + get(context, document.valueDestination, []), + document.documentDefinition, + documentId, + ); + + set(context, document.valueDestination, updatedDocuments); + + return documentId; + }); + + try { + await Promise.all(documentUploadPromises); + + onChange(updatedEntities); + } catch (error) { + console.error(error); + + toast.error('Failed to upload documents.'); + setIsCreatingEntity(false); + throw error; + } + + setIsCreatingEntity(false); + + toast.success('Entity created successfully.'); + }, [stack, element, values, createEntity, uploadDocument, entity, onChange]); + + const childrens = useChildrenDisabledOnLock(element, isCreatingEntity); + + const isShouldRenderLoading = useMemo(() => { + return isRemovingEntity || isCreatingEntity || isSyncing; + }, [isRemovingEntity, isCreatingEntity, isSyncing]); + + return ( + +
+
+ + +
+ + + +
+
+ ); +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/build-documents-creation-payload.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/build-documents-creation-payload.ts new file mode 100644 index 0000000000..c86afa205d --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/build-documents-creation-payload.ts @@ -0,0 +1,71 @@ +import { AnyObject } from '@/common'; +import { IFormElement } from '@/components/organisms/Form/DynamicForm/types'; +import { formatValueDestination, TDeepthLevelStack } from '@/components/organisms/Form/Validator'; +import { get } from 'lodash'; +import { IDocumentFieldParams } from '../../../../DocumentField'; +import { buildDocumentFormData } from '../../../../DocumentField/helpers/build-document-form-data'; +import { getFileOrFileIdFromDocumentsList } from '../../../../DocumentField/hooks/useDocumentUpload/helpers/get-file-or-fileid-from-documents-list'; +import { IEntityFieldGroupParams } from '../../../EntityFieldGroup'; + +interface IDocumentCreationDependencies { + entityId: string; + stack: TDeepthLevelStack; +} + +export interface IDocumentCreationResult { + payload: FormData; + documentDefinition: IFormElement; + valueDestination: string; +} + +export const buildDocumentsCreationPayload = ( + element: IFormElement, + context: AnyObject, + dependencies: IDocumentCreationDependencies, +): IDocumentCreationResult[] => { + const documentElements = (element.children?.filter(child => child.element === 'documentfield') || + []) as Array>; + + if (!documentElements?.length) { + return []; + } + + const { entityId, stack } = dependencies; + const documentPayload: IDocumentCreationResult[] = []; + const entities = get(context, element.valueDestination, []); + + // Outer loop for correct index calculation + for (let entityIndex = 0; entityIndex < entities.length; entityIndex++) { + // Inner loop for document elements, each entity can have multiple document fields + for (const documentElement of documentElements) { + if (!documentElement?.params?.template) { + console.warn('No template found for document field', documentElement); + continue; + } + + const documentDestination = formatValueDestination(documentElement.valueDestination, [ + ...(stack || []), + entityIndex, + ]); + + const documentFile = getFileOrFileIdFromDocumentsList( + get(context, documentDestination), + documentElement, + ); + + if (!documentFile || !(documentFile instanceof File)) { + continue; + } + + const payload = buildDocumentFormData(documentElement, { entityId }, documentFile); + + documentPayload.push({ + payload, + documentDefinition: documentElement, + valueDestination: documentDestination, + }); + } + } + + return documentPayload; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/build-entity-for-creation.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/build-entity-for-creation.ts new file mode 100644 index 0000000000..2136052e18 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/build-entity-for-creation.ts @@ -0,0 +1,20 @@ +import { AnyObject } from '@/common'; +import { IFormElement } from '@/components/organisms/Form/DynamicForm/types'; +import { IEntityFieldGroupParams, TEntityFieldGroupType } from '../../../EntityFieldGroup'; +import { IEntity } from '../../../types'; +import { transform } from '../utils/transform'; + +export const buildEntityCreationPayload = async ( + element: IFormElement, + entity: IEntity, + context: AnyObject, +): Promise<{ entity: IEntity; entityType: TEntityFieldGroupType }> => { + const entityToCreate = element.params?.httpParams?.createEntity?.transform + ? await transform(context, entity, element.params!.httpParams?.createEntity.transform) + : entity; + + return { + entity: entityToCreate, + entityType: element.params?.type as TEntityFieldGroupType, + }; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/update-entities.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/update-entities.ts new file mode 100644 index 0000000000..aae2d3a741 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/helpers/update-entities.ts @@ -0,0 +1,16 @@ +import { IEntity } from '../../../types'; + +export const updateEntities = (entitiesList: IEntity[], updatedEntity: IEntity) => { + return entitiesList.map(entity => { + if (entity.__id === updatedEntity.__id) { + const newEntity = { + ...entity, + id: updatedEntity.id, + }; + + return newEntity; + } + + return entity; + }); +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/index.ts new file mode 100644 index 0000000000..ab506406bf --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/index.ts @@ -0,0 +1 @@ +export * from './useChildrenDisabledOnLock'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/useChildrenDisabledOnLock.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/useChildrenDisabledOnLock.ts new file mode 100644 index 0000000000..d6f41992d4 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/useChildrenDisabledOnLock.ts @@ -0,0 +1,39 @@ +import { IFormElement } from '@/components/organisms/Form/DynamicForm/types'; +import { useMemo } from 'react'; + +export const useChildrenDisabledOnLock = (element: IFormElement, isLocked: boolean) => { + const { children: _children } = element; + + const children = useMemo(() => { + if (!isLocked) { + return _children; + } + + const lockChildren = (children: IFormElement[]) => { + return children.map(child => { + const element = { + ...child, + disable: [ + ...(child.disable || []), + { + engine: 'json-logic' as const, + value: { + '==': [1, 1], + }, + }, + ], + }; + + if (element.children) { + element.children = lockChildren(element.children); + } + + return element; + }); + }; + + return lockChildren(_children || []); + }, [_children, isLocked]); + + return children; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/useChildrenDisabledOnLock.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/useChildrenDisabledOnLock.unit.test.ts new file mode 100644 index 0000000000..608afad93c --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useChildrenDisabledOnLock/useChildrenDisabledOnLock.unit.test.ts @@ -0,0 +1,104 @@ +import { IFormElement } from '@/components/organisms/Form/DynamicForm/types'; +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useChildrenDisabledOnLock } from './useChildrenDisabledOnLock'; + +describe('useChildrenDisabledOnLock', () => { + const mockElement: IFormElement = { + id: 'test', + element: 'test', + valueDestination: 'test', + children: [ + { + id: 'child1', + element: 'test', + valueDestination: 'test.child1', + }, + { + id: 'child2', + element: 'test', + valueDestination: 'test.child2', + children: [ + { + id: 'grandchild', + element: 'test', + valueDestination: 'test.child2.grandchild', + }, + ], + }, + ], + disable: [], + }; + + it('should return children as-is when not locked', () => { + const { result } = renderHook(() => useChildrenDisabledOnLock(mockElement, false)); + expect(result.current).toEqual(mockElement.children); + }); + + it('should add disable rule to all children when locked', () => { + const { result } = renderHook(() => useChildrenDisabledOnLock(mockElement, true)); + + const expectedDisableRule = { + engine: 'json-logic', + value: { + '==': [1, 1], + }, + }; + + // Check first level child + expect(result.current?.[0]?.disable).toEqual([expectedDisableRule]); + + // Check second level child + expect(result.current?.[1]?.disable).toEqual([expectedDisableRule]); + + // Check grandchild + expect(result.current?.[1]?.children?.[0]?.disable).toEqual([expectedDisableRule]); + }); + + it('should handle element with no children', () => { + const elementWithNoChildren: IFormElement = { + id: 'test', + element: 'test', + valueDestination: 'test', + }; + + const { result } = renderHook(() => useChildrenDisabledOnLock(elementWithNoChildren, true)); + expect(result.current).toEqual([]); + }); + + it('should preserve existing disable rules when locking', () => { + const elementWithExistingDisable: IFormElement = { + id: 'test', + element: 'test', + valueDestination: 'test', + children: [ + { + id: 'child', + element: 'test', + valueDestination: 'test.child', + disable: [ + { + engine: 'json-logic', + value: { '===': ['test', 'test'] }, + }, + ], + }, + ], + }; + + const { result } = renderHook(() => + useChildrenDisabledOnLock(elementWithExistingDisable, true), + ); + + expect(result.current?.[0]?.disable).toEqual([ + { + engine: 'json-logic', + value: { '===': ['test', 'test'] }, + }, + { + engine: 'json-logic', + value: { '==': [1, 1] }, + }, + ]); + }); +}); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useIsEntityFieldsValid/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useIsEntityFieldsValid/index.ts new file mode 100644 index 0000000000..fdec6bff42 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useIsEntityFieldsValid/index.ts @@ -0,0 +1 @@ +export * from './useIsEntityFieldsValid'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useIsEntityFieldsValid/useIsEntityFieldsValid.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useIsEntityFieldsValid/useIsEntityFieldsValid.ts new file mode 100644 index 0000000000..cfaf864ffa --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/hooks/useIsEntityFieldsValid/useIsEntityFieldsValid.ts @@ -0,0 +1,34 @@ +import { useDynamicForm } from '@/components/organisms/Form/DynamicForm/context'; +import { useValidationSchema } from '@/components/organisms/Form/DynamicForm/hooks/internal/useValidationSchema'; +import { IFormElement } from '@/components/organisms/Form/DynamicForm/types'; +import { formatValueDestination } from '@/components/organisms/Form/Validator'; +import { validate } from '@/components/organisms/Form/Validator/utils/validate'; +import { useMemo } from 'react'; +import { useStack } from '../../../../../FieldList'; + +export const useEntityFieldsIsValid = ( + element: IFormElement, + entityGroupIndex: number, +) => { + const { values } = useDynamicForm(); + const { stack } = useStack(); + + const validationSchema = useValidationSchema(element.children || []); + + const isValid = useMemo(() => { + const validationErrors = validate( + values, + validationSchema.map(schema => ({ + ...schema, + valueDestination: formatValueDestination(schema.valueDestination!, [ + ...(stack || []), + entityGroupIndex, + ]), + })), + ); + + return validationErrors?.length === 0; + }, [validationSchema, values, stack, entityGroupIndex]); + + return isValid; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/index.ts new file mode 100644 index 0000000000..06810d1f35 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/index.ts @@ -0,0 +1 @@ +export * from './EntityFields'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/utils/transform.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/utils/transform.ts new file mode 100644 index 0000000000..aa7aca9e40 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/components/EntityFields/utils/transform.ts @@ -0,0 +1,16 @@ +import { AnyObject } from '@/common'; +import jsonata from 'jsonata'; +import { IEntity } from '../../../types'; + +export const transform = async (context: AnyObject, entity: IEntity, expression: string) => { + const transfomer = jsonata(expression); + + const transformerPayload = { + context, + entity, + }; + + const transformResult = await transfomer.evaluate(transformerPayload); + + return transformResult; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/helpers/get-entity-group-value-destination.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/helpers/get-entity-group-value-destination.ts new file mode 100644 index 0000000000..305ee66c3e --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/helpers/get-entity-group-value-destination.ts @@ -0,0 +1,16 @@ +import { TEntityFieldGroupType } from '../EntityFieldGroup'; + +export const getEntityGroupValueDestination = (type: TEntityFieldGroupType) => { + const destinationsMap: Record = { + director: 'entity.data.additionalInfo.directors', + ubo: 'entity.data.additionalInfo.ubos', + }; + + const valueDestination = destinationsMap[type]; + + if (!valueDestination) { + throw new Error(`Invalid entity group type: ${type}`); + } + + return valueDestination; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/helpers/get-entity-group-value-destination.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/helpers/get-entity-group-value-destination.unit.test.ts new file mode 100644 index 0000000000..51228de9a8 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/helpers/get-entity-group-value-destination.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { TEntityFieldGroupType } from '../EntityFieldGroup'; +import { getEntityGroupValueDestination } from './get-entity-group-value-destination'; + +describe('getEntityGroupValueDestination', () => { + it('should return correct destination path for director type', () => { + // Arrange + const type: TEntityFieldGroupType = 'director'; + const expectedPath = 'entity.data.additionalInfo.directors'; + + // Act + const result = getEntityGroupValueDestination(type); + + // Assert + expect(result).toBe(expectedPath); + }); + + it('should return correct destination path for ubo type', () => { + // Arrange + const type: TEntityFieldGroupType = 'ubo'; + const expectedPath = 'entity.data.additionalInfo.ubos'; + + // Act + const result = getEntityGroupValueDestination(type); + + // Assert + expect(result).toBe(expectedPath); + }); + + it('should throw error for invalid entity group type', () => { + // Arrange + const invalidType = 'invalid' as TEntityFieldGroupType; + + // Act & Assert + expect(() => getEntityGroupValueDestination(invalidType)).toThrow( + 'Invalid entity group type: invalid', + ); + }); +}); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/index.ts new file mode 100644 index 0000000000..7f7e5d5192 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/index.ts @@ -0,0 +1 @@ +export * from './useEntityFieldGroupList'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/useEntityFieldGroupList.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/useEntityFieldGroupList.ts new file mode 100644 index 0000000000..8bfd9bc15e --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/useEntityFieldGroupList.ts @@ -0,0 +1,64 @@ +import { useHttp } from '@/common/hooks/useHttp'; +import { useCallback } from 'react'; +import { toast } from 'sonner'; +import { useDynamicForm } from '../../../../context'; +import { useField } from '../../../../hooks/external'; +import { IFormElement } from '../../../../types'; +import { useStack } from '../../../FieldList'; +import { IEntityFieldGroupParams } from '../../EntityFieldGroup'; +import { IEntity } from '../../types'; + +export interface IUseFieldListProps { + element: IFormElement; +} + +export const useEntityFieldGroupList = ({ element }: IUseFieldListProps) => { + const { stack } = useStack(); + const { onChange, value } = useField(element, stack); + const { metadata } = useDynamicForm(); + + const { run: deleteEntity, isLoading } = useHttp( + element.params!.httpParams?.deleteEntity, + metadata, + ); + + const addItem = useCallback(async () => { + const initialEntity = { + __id: crypto.randomUUID(), + }; + onChange([...(value || []), initialEntity]); + }, [value, onChange]); + + const removeItem = useCallback( + async (id: string) => { + if (!Array.isArray(value)) { + return; + } + + const entity = value.find(entity => entity.__id === id); + + if (entity?.id) { + try { + await deleteEntity({}, { params: { entityId: entity.id } }); + } catch (error) { + toast.error('Failed to delete entity.'); + + console.error(error); + + return; + } + } + + const newValue = value.filter(entity => entity.__id !== id); + onChange(newValue); + }, + [value, deleteEntity, onChange], + ); + + return { + items: value, + isRemovingEntity: isLoading, + addItem, + removeItem, + }; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/useEntityFieldGroupList.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/useEntityFieldGroupList.unit.test.ts new file mode 100644 index 0000000000..30bb40e3ca --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntityFieldGroupList/useEntityFieldGroupList.unit.test.ts @@ -0,0 +1,143 @@ +import { useHttp } from '@/common/hooks/useHttp'; +import { TDeepthLevelStack } from '@/components/organisms/Form/Validator'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useField } from '../../../../hooks/external'; +import { useStack } from '../../../FieldList'; +import { IEntityFieldGroupParams } from '../../EntityFieldGroup'; +import { useEntityFieldGroupList } from './useEntityFieldGroupList'; + +vi.mock('../../../../hooks/external', () => ({ + useField: vi.fn(), +})); + +vi.mock('../../../FieldList', () => ({ + useStack: vi.fn(), +})); + +vi.mock('@/common/hooks/useHttp', () => ({ + useHttp: vi.fn(), +})); + +describe('useEntityFieldGroupList', () => { + const mockElement = { + id: 'test', + element: 'entityFieldGroup', + valueDestination: 'test', + params: { + httpParams: { + deleteEntity: { + url: 'http://test.com', + }, + }, + type: 'entityFieldGroup', + } as unknown as IEntityFieldGroupParams, + }; + + const mockStack: TDeepthLevelStack = []; + + beforeEach(() => { + vi.mocked(useStack).mockReturnValue({ stack: mockStack }); + vi.mocked(useField).mockReturnValue({ + onChange: vi.fn(), + value: [], + } as unknown as ReturnType); + vi.mocked(useHttp).mockReturnValue({ + run: vi.fn(), + isLoading: false, + } as unknown as ReturnType); + + Object.defineProperty(window, 'crypto', { + value: { + randomUUID: vi.fn(), + }, + }); + }); + + it('should initialize with empty array if no value provided', () => { + const { result } = renderHook(() => useEntityFieldGroupList({ element: mockElement })); + expect(result.current.items).toEqual([]); + }); + + it('should add new item with generated __id', async () => { + const mockOnChange = vi.fn(); + vi.mocked(useField).mockReturnValue({ + onChange: mockOnChange, + value: [], + } as unknown as ReturnType); + + const mockUUID = '123-456'; + vi.mocked(window.crypto.randomUUID).mockReturnValue(mockUUID); + + const { result } = renderHook(() => useEntityFieldGroupList({ element: mockElement })); + + await result.current.addItem(); + + expect(mockOnChange).toHaveBeenCalledWith([{ __id: mockUUID }]); + }); + + describe('when entity is not created', () => { + it('should remove item by id', async () => { + const mockOnChange = vi.fn(); + const mockEntities = [ + { __id: '1', name: 'Entity 1' }, + { __id: '2', name: 'Entity 2' }, + ]; + + vi.mocked(useField).mockReturnValue({ + onChange: mockOnChange, + value: mockEntities, + } as unknown as ReturnType); + + const { result } = renderHook(() => useEntityFieldGroupList({ element: mockElement })); + + await result.current.removeItem('1'); + + expect(mockOnChange).toHaveBeenCalledWith([{ __id: '2', name: 'Entity 2' }]); + }); + + it('should not remove item if value is not an array', async () => { + const mockOnChange = vi.fn(); + vi.mocked(useField).mockReturnValue({ + onChange: mockOnChange, + value: undefined as any, + } as unknown as ReturnType); + + const { result } = renderHook(() => useEntityFieldGroupList({ element: mockElement })); + + await result.current.removeItem('1'); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + }); + + describe('when entity is created', () => { + it('should remove item by id', async () => { + const deleteEntitySpy = vi.fn(); + + vi.mocked(useHttp).mockReturnValue({ + run: deleteEntitySpy, + isLoading: false, + } as unknown as ReturnType); + + const mockOnChange = vi.fn(); + const mockEntities = [ + { __id: '1', id: '1', name: 'Entity 1' }, + { __id: '2', id: '2', name: 'Entity 2' }, + ]; + + vi.mocked(useField).mockReturnValue({ + onChange: mockOnChange, + value: mockEntities, + } as unknown as ReturnType); + + const { result } = renderHook(() => useEntityFieldGroupList({ element: mockElement })); + + await result.current.removeItem('1'); + + expect(deleteEntitySpy).toHaveBeenCalledWith({}, { params: { entityId: '1' } }); + + expect(mockOnChange).toHaveBeenCalledWith([{ id: '2', __id: '2', name: 'Entity 2' }]); + }); + }); +}); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntitySync/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntitySync/index.ts new file mode 100644 index 0000000000..7d05e64ea3 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntitySync/index.ts @@ -0,0 +1 @@ +export * from './useEntitySync'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntitySync/useEntitySync.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntitySync/useEntitySync.ts new file mode 100644 index 0000000000..d99a30fd1a --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/hooks/useEntitySync/useEntitySync.ts @@ -0,0 +1,134 @@ +import { IHttpParams, useHttp } from '@/common/hooks/useHttp'; +import { + IFormElement, + TDeepthLevelStack, + useDynamicForm, + useField, +} from '@/components/organisms/Form'; +import debounce from 'lodash/debounce'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import set from 'lodash/set'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { createOrUpdateFileIdOrFileInDocuments } from '../../../DocumentField/hooks/useDocumentUpload/helpers/create-or-update-fileid-or-file-in-documents'; +import { buildDocumentsCreationPayload } from '../../components/EntityFields/helpers/build-documents-creation-payload'; +import { transform } from '../../components/EntityFields/utils/transform'; +import { IEntityFieldGroupParams } from '../../EntityFieldGroup'; +import { IEntity } from '../../types'; + +export const useEntitySync = ( + element: IFormElement, + entity: IEntity, + stack: TDeepthLevelStack, + isValid: boolean, +) => { + const [isSyncing, setIsSyncing] = useState(false); + const { values, metadata, fieldHelpers } = useDynamicForm(); + const { run: uploadDocument } = useHttp(element.params!.httpParams?.uploadDocument, metadata); + const contextRef = useRef(values); + const { value, onChange } = useField(element, stack); + + const prevEntityRef = useRef(entity); + + useEffect(() => { + contextRef.current = values; + }, [values]); + + const isValidRef = useRef(isValid); + + useEffect(() => { + isValidRef.current = isValid; + }, [isValid]); + + const { run: updateEntity } = useHttp( + element.params?.httpParams?.updateEntity.httpParams || ({} as IHttpParams), + metadata, + ); + + const debouncedSync = useCallback( + debounce(async (entity: IEntity) => { + if (!isValidRef.current) { + return; + } + + const { id: _, ...prevEntity } = prevEntityRef.current || {}; + const { id: __, ...currentEntity } = entity || {}; + + if (isEqual(prevEntity, currentEntity)) { + return; + } + + try { + setIsSyncing(true); + + // Updating entity + await updateEntity( + await transform( + contextRef.current, + entity, + element.params?.httpParams?.updateEntity.transform as string, + ), + { params: { entityId: entity.id } }, + ); + + prevEntityRef.current = entity; + } catch (error) { + toast.error('Failed to sync entity.'); + console.error(error); + + setIsSyncing(false); + } + + try { + const documentsCreationPayload = await buildDocumentsCreationPayload( + element, + contextRef.current, + { + entityId: entity.id!, + stack: stack, + }, + ); + + // Updating documents + const documentUploadPromises = documentsCreationPayload.map(async document => { + const documentId = await uploadDocument(document.payload); + + const updatedDocuments = createOrUpdateFileIdOrFileInDocuments( + get(contextRef.current, document.valueDestination, []), + document.documentDefinition, + documentId, + ); + + set(contextRef.current, document.valueDestination, updatedDocuments); + + return documentId; + }); + + await Promise.all(documentUploadPromises); + } catch (error) { + toast.error('Failed to sync documents.'); + console.error(error); + + setIsSyncing(false); + } + + onChange(structuredClone(get(contextRef.current, element.valueDestination, []))); + + setIsSyncing(false); + }, 1000), + [contextRef, isValidRef], + ); + + useEffect(() => { + if (!entity?.id) { + return; + } + + void debouncedSync(entity); + }, [entity, debouncedSync]); + + return { + isSyncing, + }; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/index.ts new file mode 100644 index 0000000000..a381bc5296 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/index.ts @@ -0,0 +1 @@ +export * from './EntityFieldGroup'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/EntityFieldProvider.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/EntityFieldProvider.tsx new file mode 100644 index 0000000000..bac00ea04b --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/EntityFieldProvider.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import { EntityFieldContext } from './entity-field-group-type.context'; +import { IEntityFieldProviderContext } from './types'; + +interface IEntityFieldProviderProps extends IEntityFieldProviderContext { + children: React.ReactNode; +} + +export const EntityFieldProvider = ({ + children, + entityFieldGroupType, + isSyncing, +}: IEntityFieldProviderProps) => { + const context = useMemo( + () => ({ + entityFieldGroupType, + isSyncing, + }), + [entityFieldGroupType, isSyncing], + ); + + return {children}; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/entity-field-group-type.context.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/entity-field-group-type.context.ts new file mode 100644 index 0000000000..c759505cf3 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/entity-field-group-type.context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import { IEntityFieldProviderContext } from './types'; + +export const EntityFieldContext = createContext({} as IEntityFieldProviderContext); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityField/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityField/index.ts new file mode 100644 index 0000000000..2bce522987 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityField/index.ts @@ -0,0 +1 @@ +export * from './useEntityField'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityField/useEntityField.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityField/useEntityField.ts new file mode 100644 index 0000000000..3b851fc830 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityField/useEntityField.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { EntityFieldContext } from '../../../entity-field-group-type.context'; + +export const useEntityField = () => { + const context = useContext(EntityFieldContext); + + if (!context) { + throw new Error('useEntityField must be used within a EntityFieldGroupTypeProvider'); + } + + return context; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityFieldGroupType/useEntityFieldGroupType.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityFieldGroupType/useEntityFieldGroupType.ts new file mode 100644 index 0000000000..016aead578 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/hooks/external/useEntityFieldGroupType/useEntityFieldGroupType.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { EntityFieldContext } from '../../../entity-field-group-type.context'; + +export const useEntityFieldGroupType = () => { + const context = useContext(EntityFieldContext); + + if (!context) { + throw new Error('useEntityFieldGroupType must be used within a EntityFieldGroupTypeProvider'); + } + + return context; +}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/index.ts new file mode 100644 index 0000000000..09fc49242c --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/index.ts @@ -0,0 +1,2 @@ +export * from './EntityFieldProvider'; +export * from './hooks/external/useEntityField'; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/types.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/types.ts new file mode 100644 index 0000000000..aed48ce7bc --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/providers/EntityFieldProvider/types.ts @@ -0,0 +1,6 @@ +import { TEntityFieldGroupType } from '../../EntityFieldGroup'; + +export interface IEntityFieldProviderContext { + entityFieldGroupType?: TEntityFieldGroupType; + isSyncing: boolean; +} diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/types/index.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/types/index.ts new file mode 100644 index 0000000000..1b87d350e5 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/types/index.ts @@ -0,0 +1,4 @@ +export interface IEntity { + id?: string; + __id?: string; +} diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/utils/delay.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/utils/delay.ts new file mode 100644 index 0000000000..7c8043e123 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/EntityFieldGroup/utils/delay.ts @@ -0,0 +1 @@ +export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/FieldList.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/FieldList.tsx index 62877022b7..3a1c4a5d6e 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/FieldList.tsx +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/FieldList.tsx @@ -12,8 +12,6 @@ import { TDynamicFormField } from '../../types'; import { useFieldList } from './hooks/useFieldList'; import { StackProvider, useStack } from './providers/StackProvider'; -export type TFieldListValueType = T[]; - export interface IFieldListParams { // jsonata expression defaultValue?: string; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/hooks/useFieldList/useFieldList.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/hooks/useFieldList/useFieldList.ts index c37014f273..329c134470 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/hooks/useFieldList/useFieldList.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FieldList/hooks/useFieldList/useFieldList.ts @@ -34,7 +34,9 @@ export const useFieldList = ({ element }: IUseFieldListProps) => { const removeItem = useCallback( (index: number) => { - if (!Array.isArray(value)) return; + if (!Array.isArray(value)) { + return; + } const newValue = value.filter((_, i) => i !== index); onChange(newValue); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/FileField.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/FileField.tsx index fdef445656..fae4d9fee6 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/FileField.tsx +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/FileField.tsx @@ -17,7 +17,7 @@ import { useFileUpload } from './hooks/useFileUpload'; export interface IFileFieldParams extends ICommonFieldParams { uploadOn?: 'change' | 'submit'; - uploadSettings?: { + uploadSettings: { url: string; resultPath: string; headers?: Record; @@ -33,7 +33,7 @@ export const FileField: TDynamicFormField = ({ element }) => { const { placeholder = 'Choose file', acceptFileFormats = undefined } = element.params || {}; const { handleChange, isUploading: disabledWhileUploading } = useFileUpload( element, - element.params, + element.params!, ); const { stack } = useStack(); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/helpers.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/helpers.ts deleted file mode 100644 index e25eba85ec..0000000000 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/helpers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import axios from 'axios'; -import get from 'lodash/get'; -import { formatString } from '../../../../utils/format-string'; -import { IFileFieldParams } from '../../FileField'; - -export const formatHeaders = ( - headers: Record, - metadata: Record = {}, -) => { - const formattedHeaders: Record = {}; - - Object.entries(headers).forEach(([key, value]) => { - const formattedValue = formatString(value, metadata); - formattedHeaders[key] = formattedValue; - }); - - return formattedHeaders; -}; - -export const uploadFile = async (file: File, params: IFileFieldParams['uploadSettings']) => { - if (!params) { - throw new Error('Upload settings are required to upload a file'); - } - - const { url, method = 'POST', headers = {} } = params; - - const formData = new FormData(); - formData.append('file', file); - - const response = await axios({ - method, - url, - headers, - data: formData, - }); - - return get(response.data, params.resultPath); -}; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/helpers.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/helpers.unit.test.ts deleted file mode 100644 index e2c234067d..0000000000 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/helpers.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import axios from 'axios'; -import { describe, expect, it, vi } from 'vitest'; -import { formatHeaders, uploadFile } from './helpers'; - -vi.mock('axios'); -const mockedAxios = vi.mocked(axios); - -describe('formatHeaders', () => { - it('should return empty object when no headers provided', () => { - const result = formatHeaders({}); - expect(result).toEqual({}); - }); - - it('should return headers without modification when no metadata matches', () => { - const headers = { - 'Content-Type': 'application/json', - Authorization: 'Bearer token', - }; - const result = formatHeaders(headers); - expect(result).toEqual(headers); - }); - - it('should replace metadata placeholders in headers', () => { - const headers = { - Authorization: 'Bearer {token}', - 'X-User-Id': '{userId}', - }; - const metadata = { - token: 'abc123', - userId: '12345', - }; - const expected = { - Authorization: 'Bearer abc123', - 'X-User-Id': '12345', - }; - const result = formatHeaders(headers, metadata); - expect(result).toEqual(expected); - }); - - it('should keep original placeholder if metadata key not found', () => { - const headers = { - Authorization: 'Bearer {token}', - 'X-User-Id': '{userId}', - }; - const metadata = { - token: 'abc123', - }; - const expected = { - Authorization: 'Bearer abc123', - 'X-User-Id': '{userId}', - }; - const result = formatHeaders(headers, metadata); - expect(result).toEqual(expected); - }); -}); - -describe('uploadFile', () => { - const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' }); - const mockParams = { - url: 'http://test.com/upload', - method: 'POST' as const, - headers: { 'Content-Type': 'multipart/form-data' }, - resultPath: 'fileUrl', - }; - - it('should throw error if no params provided', async () => { - await expect(uploadFile(mockFile, undefined)).rejects.toThrow( - 'Upload settings are required to upload a file', - ); - }); - - it('should upload file successfully and return result from specified path', async () => { - const mockResponse = { - data: { - fileUrl: 'http://test.com/files/test.txt', - }, - }; - - mockedAxios.mockResolvedValueOnce(mockResponse); - - const result = await uploadFile(mockFile, mockParams); - - expect(mockedAxios).toHaveBeenCalledWith({ - method: 'POST', - url: mockParams.url, - headers: mockParams.headers, - data: expect.any(FormData), - }); - expect(result).toBe(mockResponse.data.fileUrl); - }); - - it('should use POST as default method if not specified', async () => { - const paramsWithoutMethod = { - url: 'http://test.com/upload', - headers: {}, - resultPath: 'data.fileUrl', - }; - - mockedAxios.mockResolvedValueOnce({ data: { fileUrl: 'test' } }); - - await uploadFile(mockFile, paramsWithoutMethod); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - }), - ); - }); - - it('should use empty object as default headers if not specified', async () => { - const paramsWithoutHeaders = { - url: 'http://test.com/upload', - method: 'POST' as const, - resultPath: 'data.fileUrl', - }; - - mockedAxios.mockResolvedValueOnce({ data: { fileUrl: 'test' } }); - - await uploadFile(mockFile, paramsWithoutHeaders); - - expect(mockedAxios).toHaveBeenCalledWith( - expect.objectContaining({ - headers: {}, - }), - ); - }); -}); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.ts index e0955fd21c..6523d8eb2f 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.ts @@ -1,27 +1,27 @@ import { AnyObject } from '@/common'; +import { useHttp } from '@/common/hooks/useHttp'; import set from 'lodash/set'; -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { useDynamicForm } from '../../../../context'; import { useElement, useField } from '../../../../hooks/external'; import { useTaskRunner } from '../../../../providers/TaskRunner/hooks/useTaskRunner'; import { ITask } from '../../../../providers/TaskRunner/types'; import { IFormElement } from '../../../../types'; -import { formatString } from '../../../../utils/format-string'; import { useStack } from '../../../FieldList/providers/StackProvider'; import { IFileFieldParams } from '../../FileField'; -import { formatHeaders, uploadFile } from './helpers'; export const useFileUpload = ( element: IFormElement, - params: IFileFieldParams = {}, + params: IFileFieldParams, ) => { const { uploadOn = 'change' } = params; const { stack } = useStack(); const { id } = useElement(element, stack); const { addTask, removeTask } = useTaskRunner(); - const [isUploading, setIsUploading] = useState(false); const { metadata } = useDynamicForm(); + const { run, isLoading } = useHttp(element.params!.uploadSettings, metadata); + const { onChange } = useField(element); const handleChange = useCallback( @@ -37,26 +37,15 @@ export const useFileUpload = ( return; } - const uploadParams = { - ...uploadSettings, - method: uploadSettings?.method || 'POST', - headers: formatHeaders(uploadSettings?.headers || {}, metadata), - url: formatString(uploadSettings?.url || '', metadata), - }; - if (uploadOn === 'change') { try { - setIsUploading(true); + const formData = new FormData(); + formData.append('file', e.target?.files?.[0] as File); - const result = await uploadFile( - e.target?.files?.[0] as File, - uploadParams as IFileFieldParams['uploadSettings'], - ); + const result = await run(formData); onChange(result); } catch (error) { console.error('Failed to upload file.', error); - } finally { - setIsUploading(false); } } @@ -65,11 +54,10 @@ export const useFileUpload = ( const taskRun = async (context: AnyObject) => { try { - setIsUploading(true); - const result = await uploadFile( - e.target?.files?.[0] as File, - uploadParams as IFileFieldParams['uploadSettings'], - ); + const formData = new FormData(); + formData.append('file', e.target?.files?.[0] as File); + + const result = await run(formData); set(context, element.valueDestination, result); return context; @@ -77,8 +65,6 @@ export const useFileUpload = ( console.error('Failed to upload file.', error); return context; - } finally { - setIsUploading(false); } }; @@ -90,11 +76,11 @@ export const useFileUpload = ( addTask(task); } }, - [uploadOn, params, metadata, addTask, removeTask, onChange, id, element], + [uploadOn, params, addTask, removeTask, onChange, id, element, run], ); return { - isUploading, + isUploading: isLoading, handleChange, }; }; diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.unit.test.ts index d8f3b3b27d..64671e7cc1 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.unit.test.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/fields/FileField/hooks/useFileUpload/useFileUpload.unit.test.ts @@ -1,150 +1,203 @@ -import { act, renderHook } from '@testing-library/react'; +import { useHttp } from '@/common/hooks/useHttp'; +import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { formatHeaders, uploadFile } from './helpers'; +import { useDynamicForm } from '../../../../context'; +import { useElement, useField } from '../../../../hooks/external'; +import { useTaskRunner } from '../../../../providers/TaskRunner/hooks/useTaskRunner'; +import { useStack } from '../../../FieldList/providers/StackProvider'; import { useFileUpload } from './useFileUpload'; -vi.mock('./helpers', () => ({ - uploadFile: vi.fn(), - formatHeaders: vi.fn(), -})); - -vi.mock('../../../../context', () => ({ - useDynamicForm: () => ({ - metadata: { test: 'metadata' }, - }), -})); - -vi.mock('../../../../hooks/external', () => ({ - useElement: () => ({ id: 'test-id' }), - useField: () => ({ onChange: vi.fn() }), -})); - -vi.mock('../../../../providers/TaskRunner/hooks/useTaskRunner', () => ({ - useTaskRunner: () => ({ - addTask: vi.fn(), - removeTask: vi.fn(), - }), -})); - -vi.mock('../../../FieldList/providers/StackProvider', () => ({ - useStack: () => ({ stack: [] }), -})); - -const mockedUploadFile = vi.mocked(uploadFile); -const mockedFormatHeaders = vi.mocked(formatHeaders); +vi.mock('@/common/hooks/useHttp'); +vi.mock('../../../../hooks/external'); +vi.mock('../../../../providers/TaskRunner/hooks/useTaskRunner'); +vi.mock('../../../../context'); +vi.mock('../../../FieldList/providers/StackProvider'); describe('useFileUpload', () => { const mockElement = { - id: 'test-field', - element: 'file', - valueDestination: 'file', + id: 'test-id', + element: 'filefield', + params: { + uploadSettings: { + url: 'test-url', + resultPath: 'test.path', + }, + }, + valueDestination: 'test.destination', }; - const createEvent = (file: File) => - ({ - target: { - files: [file], - }, - } as unknown as React.ChangeEvent); + const mockParams = { + uploadSettings: { + url: 'test-url', + resultPath: 'test.path', + }, + }; + + const mockFile = new File(['test'], 'test.txt'); + const mockEvent = { + target: { + files: [mockFile], + }, + } as unknown as React.ChangeEvent; + + const mockOnChange = vi.fn(); + const mockAddTask = vi.fn(); + const mockRemoveTask = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - }); - it('should handle file change without upload settings', async () => { - const { result } = renderHook(() => useFileUpload(mockElement, {})); - const mockFile = new File(['test'], 'test.txt'); + vi.mocked(useStack).mockReturnValue({ stack: [] }); + vi.mocked(useElement).mockReturnValue({ + id: 'test-id', + originId: 'test-origin-id', + hidden: false, + }); + vi.mocked(useTaskRunner).mockReturnValue({ + addTask: mockAddTask, + removeTask: mockRemoveTask, + tasks: [], + isRunning: false, + runTasks: vi.fn(), + }); + vi.mocked(useDynamicForm).mockReturnValue({ + metadata: {}, + values: {}, + touched: {}, + elementsMap: {}, + fieldHelpers: {}, + } as ReturnType); + vi.mocked(useHttp).mockReturnValue({ + run: vi.fn().mockResolvedValue('uploaded-file-url'), + isLoading: false, + error: null, + }); + vi.mocked(useField).mockReturnValue({ + value: undefined, + touched: false, + disabled: false, + onChange: mockOnChange, + onBlur: vi.fn(), + onFocus: vi.fn(), + }); + }); - await act(async () => { - await result.current.handleChange(createEvent(mockFile)); + it('should handle file upload on change', async () => { + vi.mocked(useHttp).mockReturnValue({ + run: vi.fn().mockResolvedValue('uploaded-file-url'), + isLoading: false, + error: null, }); - expect(mockedUploadFile).not.toHaveBeenCalled(); - }); + const { result } = renderHook(() => useFileUpload(mockElement, mockParams)); - it('should upload file immediately when uploadOn is "change"', async () => { - const uploadSettings = { - url: 'test-url', - resultPath: 'data.url', - headers: { 'Content-Type': 'application/json' }, - }; + await result.current.handleChange(mockEvent); - mockedUploadFile.mockResolvedValue('uploaded-file-url'); - mockedFormatHeaders.mockReturnValue({ 'Content-Type': 'application/json' }); + expect(useHttp).toHaveBeenCalledWith(mockElement.params.uploadSettings, {}); + expect(mockOnChange).toHaveBeenCalledWith('uploaded-file-url'); + }); - const { result } = renderHook(() => - useFileUpload(mockElement, { - uploadOn: 'change', - uploadSettings, - }), - ); + it('should handle file upload on submit', async () => { + const mockParamsWithSubmit = { + uploadSettings: { + url: 'test-url', + resultPath: 'test.path', + }, + uploadOn: 'submit' as const, + }; - const mockFile = new File(['test'], 'test.txt'); + const { result } = renderHook(() => useFileUpload(mockElement, mockParamsWithSubmit)); - await act(async () => { - await result.current.handleChange(createEvent(mockFile)); - }); + await result.current.handleChange(mockEvent); - expect(mockedUploadFile).toHaveBeenCalledWith( - mockFile, - expect.objectContaining({ - url: 'test-url', - method: 'POST', - resultPath: 'data.url', - headers: { 'Content-Type': 'application/json' }, - }), - ); + expect(mockOnChange).toHaveBeenCalledWith(mockFile); + expect(mockAddTask).toHaveBeenCalled(); }); - it('should queue upload task when uploadOn is "submit"', async () => { - const uploadSettings = { - url: 'test-url', - resultPath: 'data.url', - headers: { 'Content-Type': 'application/json' }, + it('should handle missing upload settings', async () => { + const mockElementWithoutSettings = { + ...mockElement, + params: { + uploadSettings: undefined as any, + }, }; + const mockParamsWithoutSettings = { + uploadSettings: undefined, + } as any; + + const consoleSpy = vi.spyOn(console, 'log'); const { result } = renderHook(() => - useFileUpload(mockElement, { - uploadOn: 'submit', - uploadSettings, - }), + useFileUpload(mockElementWithoutSettings, mockParamsWithoutSettings), ); - const mockFile = new File(['test'], 'test.txt'); + await result.current.handleChange(mockEvent); + + expect(mockOnChange).toHaveBeenCalledWith(mockFile); + expect(consoleSpy).toHaveBeenCalledWith('Failed to upload, no upload settings provided'); + }); - await act(async () => { - await result.current.handleChange(createEvent(mockFile)); + it('should handle upload error on change', async () => { + vi.mocked(useHttp).mockReturnValue({ + run: vi.fn().mockRejectedValue(new Error('Upload failed')), + isLoading: false, + error: null, }); - expect(mockedUploadFile).not.toHaveBeenCalled(); + const consoleSpy = vi.spyOn(console, 'error'); + + const { result } = renderHook(() => useFileUpload(mockElement, mockParams)); + + await result.current.handleChange(mockEvent); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to upload file.', expect.any(Error)); }); - it('should handle upload errors gracefully', async () => { - const uploadSettings = { - url: 'test-url', - resultPath: 'data.url', - headers: { 'Content-Type': 'application/json' }, + it('should handle upload error on submit', async () => { + const mockParamsWithSubmit = { + uploadSettings: { + url: 'test-url', + resultPath: 'test.path', + }, + uploadOn: 'submit' as const, }; - mockedUploadFile.mockRejectedValue(new Error('Upload failed')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn() as any); + vi.mocked(useHttp).mockReturnValue({ + run: vi.fn().mockRejectedValue(new Error('Upload failed')), + isLoading: false, + error: null, + }); - const { result } = renderHook(() => - useFileUpload(mockElement, { - uploadOn: 'change', - uploadSettings, - }), - ); + const consoleSpy = vi.spyOn(console, 'error'); - const mockFile = new File(['test'], 'test.txt'); + const { result } = renderHook(() => useFileUpload(mockElement, mockParamsWithSubmit)); - await act(async () => { - await result.current.handleChange(createEvent(mockFile)); - }); + await result.current.handleChange(mockEvent); + + const addedTask = mockAddTask.mock.calls[0][0]; + const context = {}; + await addedTask.run(context); expect(consoleSpy).toHaveBeenCalledWith('Failed to upload file.', expect.any(Error)); - expect(result.current.isUploading).toBe(false); + }); + + it('should remove existing task before handling change', async () => { + const { result } = renderHook(() => useFileUpload(mockElement, mockParams)); + + await result.current.handleChange(mockEvent); + + expect(mockRemoveTask).toHaveBeenCalledWith('test-id'); + }); + + it('should return correct loading state', () => { + vi.mocked(useHttp).mockReturnValue({ + run: vi.fn(), + isLoading: true, + error: null, + }); + + const { result } = renderHook(() => useFileUpload(mockElement, mockParams)); - consoleSpy.mockRestore(); + expect(result.current.isUploading).toBe(true); }); }); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/helpers/upload-file/upload-file.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/helpers/upload-file/upload-file.unit.test.ts index 0170bfdd7f..30a4260822 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/helpers/upload-file/upload-file.unit.test.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/helpers/upload-file/upload-file.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import axios from 'axios'; +import { IDocumentFieldParams } from '../../fields'; import { uploadFile } from './upload-file'; vi.mock('axios'); @@ -16,9 +17,11 @@ describe('uploadFile', () => { }; it('should throw error if no params provided', async () => { - await expect(uploadFile(mockFile, undefined)).rejects.toThrow( - 'Upload settings are required to upload a file', - ); + mockedAxios.mockRejectedValueOnce(new Error('Upload settings are required to upload a file')); + + await expect( + uploadFile(mockFile, {} as IDocumentFieldParams['uploadSettings']), + ).rejects.toThrow('Upload settings are required to upload a file'); }); it('should upload file successfully and return result from specified path', async () => { diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useClear/value-cleaners/documentfield-value-cleaner.unit.test.ts b/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useClear/value-cleaners/documentfield-value-cleaner.unit.test.ts index de90ffe9c2..e8a6b55a53 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useClear/value-cleaners/documentfield-value-cleaner.unit.test.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useClear/value-cleaners/documentfield-value-cleaner.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { DOCUMENT_FIELD_TYPE, IDocumentFieldParams } from '../../../../fields'; +import { DOCUMENT_FIELD_TYPE, IDocumentFieldParams, IDocumentTemplate } from '../../../../fields'; import { TBaseFields } from '../../../../repositories'; import { IFormElement } from '../../../../types'; import { documentFieldValueCleaner } from './documentfield-value-cleaner'; @@ -12,9 +12,11 @@ describe('documentFieldValueCleaner', () => { params: { template: { id: 'template-1', - pages: [], - }, - }, + } as IDocumentTemplate, + documentType: 'document', + documentVariant: 'variant', + httpParams: {} as IDocumentFieldParams['httpParams'], + } as IDocumentFieldParams, }; it('should return undefined if value is not an array', () => { diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useValues/useValues.ts b/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useValues/useValues.ts index b97ad5f007..322ac2efa7 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useValues/useValues.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/hooks/internal/useValues/useValues.ts @@ -1,3 +1,4 @@ +import get from 'lodash/get'; import set from 'lodash/set'; import { useCallback, useState } from 'react'; @@ -26,8 +27,25 @@ export const useValues = ({ (fieldName: string, valueDestination: string, newValue: unknown) => { setValuesState(prev => { const newValues = { ...prev }; + const parentValueDestination = valueDestination.split('.').slice(0, -1).join('.'); + set(newValues, valueDestination, newValue); + if (parentValueDestination) { + const parentValue = get(prev, parentValueDestination); + let newParentValue: any; + + if (Array.isArray(parentValue)) { + newParentValue = [...parentValue]; + } + + if (typeof parentValue === 'object' && !Array.isArray(parentValue)) { + newParentValue = { ...parentValue }; + } + + set(newValues, parentValueDestination, newParentValue); + } + onFieldChange?.(fieldName, newValue, newValues); onChange?.(newValues); diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/providers/TaskRunner/TaskRunner.tsx b/packages/ui/src/components/organisms/Form/DynamicForm/providers/TaskRunner/TaskRunner.tsx index b9debb9377..bb0eeb649f 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/providers/TaskRunner/TaskRunner.tsx +++ b/packages/ui/src/components/organisms/Form/DynamicForm/providers/TaskRunner/TaskRunner.tsx @@ -22,7 +22,9 @@ export const TaskRunner = ({ children }: ITaskRunnerProps) => { const runTasks = useCallback( async (context: TContext) => { - if (isRunning) return context; + if (isRunning) { + return context; + } setIsRunning(true); @@ -32,6 +34,8 @@ export const TaskRunner = ({ children }: ITaskRunnerProps) => { setIsRunning(false); + setTasks([]); + return context; }, [tasks, isRunning], diff --git a/packages/ui/src/components/organisms/Form/DynamicForm/repositories/fields-repository.ts b/packages/ui/src/components/organisms/Form/DynamicForm/repositories/fields-repository.ts index 2c223674ed..5215aebed1 100644 --- a/packages/ui/src/components/organisms/Form/DynamicForm/repositories/fields-repository.ts +++ b/packages/ui/src/components/organisms/Form/DynamicForm/repositories/fields-repository.ts @@ -4,6 +4,7 @@ import { CheckboxField } from '../fields/CheckboxField'; import { CheckboxListField } from '../fields/CheckboxList'; import { DateField } from '../fields/DateField'; import { DOCUMENT_FIELD_TYPE, DocumentField } from '../fields/DocumentField'; +import { EntityFieldGroup } from '../fields/EntityFieldGroup'; import { FieldList } from '../fields/FieldList'; import { FileField } from '../fields/FileField'; import { MultiselectField } from '../fields/MultiselectField'; @@ -23,6 +24,7 @@ export const baseFields = { multiselectfield: MultiselectField, textfield: TextField, fieldlist: FieldList, + entityfieldgroup: EntityFieldGroup, selectfield: SelectField, submitbutton: SubmitButton, phonefield: PhoneField, diff --git a/packages/ui/src/components/organisms/Form/Validator/types/index.ts b/packages/ui/src/components/organisms/Form/Validator/types/index.ts index 2068e1a237..62cea814fc 100644 --- a/packages/ui/src/components/organisms/Form/Validator/types/index.ts +++ b/packages/ui/src/components/organisms/Form/Validator/types/index.ts @@ -14,7 +14,8 @@ export type TBaseValidators = | 'pattern' | 'minimum' | 'maximum' - | 'format'; + | 'format' + | 'document'; export interface ICommonValidator { type: TValidatorType; diff --git a/packages/ui/src/components/organisms/Form/Validator/validators/document/document-validator.ts b/packages/ui/src/components/organisms/Form/Validator/validators/document/document-validator.ts new file mode 100644 index 0000000000..a0b79d359e --- /dev/null +++ b/packages/ui/src/components/organisms/Form/Validator/validators/document/document-validator.ts @@ -0,0 +1,30 @@ +import { TDocument } from '@ballerine/common'; +import { TBaseValidators, TValidator } from '../../types'; +import { IDocumentValidatorParams } from './types'; + +export const documentValidator: TValidator< + TDocument[], + IDocumentValidatorParams, + TBaseValidators | 'document' +> = (value, params) => { + const { message = 'Document is required' } = params; + const { id, pageNumber = 0, pageProperty = 'ballerineFileId' } = params.value; + + if (!Array.isArray(value) || !value.length) { + throw new Error(message); + } + + const document = value.find(doc => doc.id === id); + + if (!document) { + throw new Error(message); + } + + const documentValue = document.pages[pageNumber]?.[pageProperty]; + + if (!documentValue) { + throw new Error(message); + } + + return true; +}; diff --git a/packages/ui/src/components/organisms/Form/Validator/validators/document/document-validator.unit.test.ts b/packages/ui/src/components/organisms/Form/Validator/validators/document/document-validator.unit.test.ts new file mode 100644 index 0000000000..91736e815f --- /dev/null +++ b/packages/ui/src/components/organisms/Form/Validator/validators/document/document-validator.unit.test.ts @@ -0,0 +1,80 @@ +import { TDocument } from '@ballerine/common'; +import { ICommonValidator, TBaseValidators } from '@ballerine/ui'; +import { describe, expect, it } from 'vitest'; +import { documentValidator } from './document-validator'; +import { IDocumentValidatorParams } from './types'; + +describe('documentValidator', () => { + const mockParams = { + message: 'Test message', + value: { + id: 'test-id', + pageNumber: 0, + pageProperty: 'ballerineFileId', + }, + } as ICommonValidator; + + it('should throw error when value is not an array', () => { + expect(() => documentValidator(null as unknown as TDocument[], mockParams)).toThrow( + 'Test message', + ); + }); + + it('should throw error when array is empty', () => { + expect(() => documentValidator([], mockParams)).toThrow('Test message'); + }); + + it('should throw error when document with specified id is not found', () => { + const mockDocuments = [{ id: 'wrong-id', pages: [] }] as unknown as TDocument[]; + + expect(() => documentValidator(mockDocuments, mockParams)).toThrow('Test message'); + }); + + it('should throw error when document page does not exist', () => { + const mockDocuments = [{ id: 'test-id', pages: [] }] as unknown as TDocument[]; + + expect(() => documentValidator(mockDocuments, mockParams)).toThrow('Test message'); + }); + + it('should throw error when document page property does not exist', () => { + const mockDocuments = [ + { + id: 'test-id', + pages: [{}], + propertiesSchema: {}, + }, + ] as TDocument[]; + + expect(() => documentValidator(mockDocuments, mockParams)).toThrow('Test message'); + }); + + it('should return true for valid document', () => { + const mockDocuments = [ + { + id: 'test-id', + pages: [{ ballerineFileId: 'valid-file-id' }], + propertiesSchema: {}, + }, + ] as unknown as TDocument[]; + + expect(documentValidator(mockDocuments, mockParams)).toBe(true); + }); + + it('should use default values when not provided in params', () => { + const mockDocuments = [ + { + id: 'test-id', + pages: [{ ballerineFileId: 'valid-file-id' }], + propertiesSchema: {}, + }, + ] as unknown as TDocument[]; + + const minimalParams = { + value: { + id: 'test-id', + }, + } as ICommonValidator; + + expect(documentValidator(mockDocuments, minimalParams)).toBe(true); + }); +}); diff --git a/packages/ui/src/components/organisms/Form/Validator/validators/document/index.ts b/packages/ui/src/components/organisms/Form/Validator/validators/document/index.ts new file mode 100644 index 0000000000..7fd5cef0c2 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/Validator/validators/document/index.ts @@ -0,0 +1,2 @@ +export * from './document-validator'; +export * from './types'; diff --git a/packages/ui/src/components/organisms/Form/Validator/validators/document/types.ts b/packages/ui/src/components/organisms/Form/Validator/validators/document/types.ts new file mode 100644 index 0000000000..90ae5419d2 --- /dev/null +++ b/packages/ui/src/components/organisms/Form/Validator/validators/document/types.ts @@ -0,0 +1,5 @@ +export interface IDocumentValidatorParams { + id: string; + pageNumber?: number; + pageProperty?: string; +} diff --git a/packages/ui/src/components/organisms/Form/Validator/validators/index.ts b/packages/ui/src/components/organisms/Form/Validator/validators/index.ts index bcdb1c19da..a34bfbf580 100644 --- a/packages/ui/src/components/organisms/Form/Validator/validators/index.ts +++ b/packages/ui/src/components/organisms/Form/Validator/validators/index.ts @@ -1,4 +1,5 @@ import { TBaseValidators, TValidator } from '../types'; +import { documentValidator } from './document'; import { formatValidator } from './format'; import { maxLengthValidator } from './max-length'; import { maximumValueValidator } from './maximum'; @@ -15,6 +16,7 @@ export const baseValidatorsMap: Record> = minimum: minimumValueValidator, maximum: maximumValueValidator, format: formatValidator, + document: documentValidator, }; export const validatorsExtends: Record> = {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e41d756b5..a7956f2e8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1880,6 +1880,9 @@ importers: recharts: specifier: ^2.7.2 version: 2.9.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + sonner: + specifier: ^1.4.3 + version: 1.4.3(react-dom@18.2.0)(react@18.2.0) string-ts: specifier: ^1.2.0 version: 1.2.0 @@ -3376,7 +3379,7 @@ packages: engines: {node: '>=18.14.1'} dependencies: ci-info: 3.9.0 - debug: 4.3.6 + debug: 4.4.0 dlv: 1.1.3 dset: 3.1.3 is-docker: 3.0.0 @@ -4860,7 +4863,7 @@ packages: '@babel/traverse': 7.23.7 '@babel/types': 7.23.6 convert-source-map: 2.0.0 - debug: 4.3.6 + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5031,7 +5034,7 @@ packages: '@babel/core': 7.17.9 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.6 + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 semver: 6.3.1 @@ -7355,7 +7358,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.6 '@babel/types': 7.23.6 - debug: 4.3.6 + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -9490,7 +9493,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.6 + debug: 4.4.0 espree: 9.6.1 globals: 13.23.0 ignore: 5.3.0 @@ -9507,7 +9510,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.6 + debug: 4.4.0 espree: 9.6.1 globals: 13.23.0 ignore: 5.3.0 @@ -9523,7 +9526,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.6 + debug: 4.4.0 espree: 9.6.1 globals: 13.23.0 ignore: 5.3.0 @@ -9702,7 +9705,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.6 + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -9713,7 +9716,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 2.0.1 - debug: 4.3.6 + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -10550,7 +10553,7 @@ packages: '@open-draft/until': 1.0.3 '@types/debug': 4.1.12 '@xmldom/xmldom': 0.8.10 - debug: 4.3.6 + debug: 4.4.0 headers-polyfill: 3.2.5 outvariant: 1.4.3 strict-event-emitter: 0.2.8 @@ -17149,7 +17152,7 @@ packages: vite: ^4.0.0 dependencies: '@sveltejs/vite-plugin-svelte': 2.5.2(svelte@3.59.2)(vite@4.5.3) - debug: 4.3.6 + debug: 4.4.0 svelte: 3.59.2 vite: 4.5.3(@types/node@20.9.2) transitivePeerDependencies: @@ -19706,7 +19709,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.5) '@typescript-eslint/utils': 5.62.0(eslint@8.22.0)(typescript@4.9.5) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.22.0 tsutils: 3.21.0(typescript@4.9.5) typescript: 4.9.5 @@ -19726,7 +19729,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) '@typescript-eslint/utils': 5.62.0(eslint@8.22.0)(typescript@5.5.4) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.22.0 tsutils: 3.21.0(typescript@5.5.4) typescript: 5.5.4 @@ -19746,7 +19749,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.3) '@typescript-eslint/utils': 5.62.0(eslint@8.54.0)(typescript@4.9.3) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.54.0 tsutils: 3.21.0(typescript@4.9.3) typescript: 4.9.3 @@ -19766,7 +19769,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.5) '@typescript-eslint/utils': 5.62.0(eslint@8.54.0)(typescript@4.9.5) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.54.0 tsutils: 3.21.0(typescript@4.9.5) typescript: 4.9.5 @@ -19786,7 +19789,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) '@typescript-eslint/utils': 5.62.0(eslint@8.54.0)(typescript@5.1.6) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.54.0 tsutils: 3.21.0(typescript@5.1.6) typescript: 5.1.6 @@ -19806,7 +19809,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) '@typescript-eslint/utils': 5.62.0(eslint@8.54.0)(typescript@5.5.4) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.54.0 tsutils: 3.21.0(typescript@5.5.4) typescript: 5.5.4 @@ -19826,7 +19829,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.5.4) '@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.5.4) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.53.0 ts-api-utils: 1.0.3(typescript@5.5.4) typescript: 5.5.4 @@ -19846,7 +19849,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 6.14.0(typescript@5.2.2) '@typescript-eslint/utils': 6.14.0(eslint@8.55.0)(typescript@5.2.2) - debug: 4.3.6 + debug: 4.4.0 eslint: 8.55.0 ts-api-utils: 1.0.3(typescript@5.2.2) typescript: 5.2.2 @@ -19890,7 +19893,7 @@ packages: dependencies: '@typescript-eslint/types': 4.33.0 '@typescript-eslint/visitor-keys': 4.33.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -19911,7 +19914,7 @@ packages: dependencies: '@typescript-eslint/types': 4.33.0 '@typescript-eslint/visitor-keys': 4.33.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -19932,7 +19935,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -19953,7 +19956,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -19974,7 +19977,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -19995,7 +19998,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -20016,7 +20019,7 @@ packages: dependencies: '@typescript-eslint/types': 6.11.0 '@typescript-eslint/visitor-keys': 6.11.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -20037,7 +20040,7 @@ packages: dependencies: '@typescript-eslint/types': 6.14.0 '@typescript-eslint/visitor-keys': 6.14.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -20058,7 +20061,7 @@ packages: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.6 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -21170,7 +21173,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.6 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -21637,7 +21640,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 astrojs-compiler-sync: 0.3.3(@astrojs/compiler@1.8.2) - debug: 4.3.6 + debug: 4.4.0 eslint-visitor-keys: 3.4.3 espree: 9.6.1 semver: 7.5.4 @@ -24472,7 +24475,7 @@ packages: resolution: {integrity: sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==} engines: {node: '>= 8.0'} dependencies: - debug: 4.3.6 + debug: 4.4.0 readable-stream: 3.6.2 split-ca: 1.0.1 ssh2: 1.14.0 @@ -25143,7 +25146,7 @@ packages: peerDependencies: esbuild: '>=0.12 <1' dependencies: - debug: 4.3.6 + debug: 4.4.0 esbuild: 0.18.20 transitivePeerDependencies: - supports-color @@ -26709,7 +26712,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.3.6 + debug: 4.4.0 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -28202,7 +28205,7 @@ packages: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.3.6 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: true @@ -28230,7 +28233,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.6 + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -29083,7 +29086,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.3.6 + debug: 4.4.0 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -30287,6 +30290,7 @@ packages: /lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. /lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} @@ -32219,7 +32223,7 @@ packages: resolution: {integrity: sha512-96yVFal0c/W1lG7mmfRe7eO+hovrhJYd2obzzOZ90f6fjpeU/XNvd9cYHZKZAQJumDfhXgoTpkpJ9pvMj+hqHw==} engines: {node: '>= 10.13'} dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.4.0 json-stringify-safe: 5.0.1 propagate: 2.0.1 transitivePeerDependencies: @@ -33801,7 +33805,7 @@ packages: engines: {node: '>=8.16.0'} dependencies: '@types/mime-types': 2.1.4 - debug: 4.3.6 + debug: 4.4.0 extract-zip: 1.7.0 https-proxy-agent: 4.0.0 mime: 2.6.0 @@ -36403,7 +36407,7 @@ packages: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.3.6 + debug: 4.4.0 fast-safe-stringify: 2.1.1 form-data: 4.0.0 formidable: 2.1.2 @@ -38540,7 +38544,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.6 + debug: 4.4.0 mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 @@ -38564,7 +38568,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.6 + debug: 4.4.0 mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 @@ -38586,7 +38590,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.6 + debug: 4.4.0 mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 diff --git a/services/workflows-service/prisma/data-migrations b/services/workflows-service/prisma/data-migrations index 76e65b9605..cd3b6508d5 160000 --- a/services/workflows-service/prisma/data-migrations +++ b/services/workflows-service/prisma/data-migrations @@ -1 +1 @@ -Subproject commit 76e65b9605d410aa59f05316302edd28c929a594 +Subproject commit cd3b6508d535d90fdc94000b3d805279bdbd114d diff --git a/services/workflows-service/prisma/migrations/20250129142137_documents_init/migration.sql b/services/workflows-service/prisma/migrations/20250129142137_documents_init/migration.sql new file mode 100644 index 0000000000..786adbfe15 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20250129142137_documents_init/migration.sql @@ -0,0 +1,81 @@ +/* + Warnings: + + - You are about to drop the column `documents` on the `Business` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "DocumentStatus" AS ENUM ('provided', 'unprovided', 'requested'); + +-- CreateEnum +CREATE TYPE "DocumentDecision" AS ENUM ('approved', 'rejected', 'revisions'); + +-- CreateEnum +CREATE TYPE "DocumentFileType" AS ENUM ('selfie', 'document', 'other'); + +-- CreateEnum +CREATE TYPE "DocumentFileVariant" AS ENUM ('front', 'back', 'other'); + +-- AlterTable +ALTER TABLE "Business" DROP COLUMN "documents"; + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "category" TEXT NOT NULL, + "type" TEXT NOT NULL, + "issuingVersion" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "status" "DocumentStatus" NOT NULL, + "decision" "DocumentDecision", + "properties" JSONB NOT NULL, + "businessId" TEXT, + "endUserId" TEXT, + "workflowRuntimeDataId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentFile" ( + "id" TEXT NOT NULL, + "type" "DocumentFileType" NOT NULL, + "variant" "DocumentFileVariant" NOT NULL, + "page" INTEGER NOT NULL, + "documentId" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + + CONSTRAINT "DocumentFile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Document_businessId_idx" ON "Document"("businessId"); + +-- CreateIndex +CREATE INDEX "Document_endUserId_idx" ON "Document"("endUserId"); + +-- CreateIndex +CREATE INDEX "Document_workflowRuntimeDataId_idx" ON "Document"("workflowRuntimeDataId"); + +-- CreateIndex +CREATE INDEX "DocumentFile_documentId_idx" ON "DocumentFile"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentFile_fileId_idx" ON "DocumentFile"("fileId"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_endUserId_fkey" FOREIGN KEY ("endUserId") REFERENCES "EndUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_workflowRuntimeDataId_fkey" FOREIGN KEY ("workflowRuntimeDataId") REFERENCES "WorkflowRuntimeData"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentFile" ADD CONSTRAINT "DocumentFile_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentFile" ADD CONSTRAINT "DocumentFile_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/services/workflows-service/prisma/migrations/20250129151319_documents_project/migration.sql b/services/workflows-service/prisma/migrations/20250129151319_documents_project/migration.sql new file mode 100644 index 0000000000..fd5ce226a8 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20250129151319_documents_project/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - Added the required column `projectId` to the `Document` table without a default value. This is not possible if the table is not empty. + - Added the required column `projectId` to the `DocumentFile` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "projectId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "DocumentFile" ADD COLUMN "projectId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentFile" ADD CONSTRAINT "DocumentFile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/services/workflows-service/prisma/migrations/20250129160127_documents_issuing_country/migration.sql b/services/workflows-service/prisma/migrations/20250129160127_documents_issuing_country/migration.sql new file mode 100644 index 0000000000..07b7f897e2 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20250129160127_documents_issuing_country/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `issuingCountry` to the `Document` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "issuingCountry" TEXT NOT NULL; diff --git a/services/workflows-service/prisma/migrations/20250202144546_added_document_file_cascade/migration.sql b/services/workflows-service/prisma/migrations/20250202144546_added_document_file_cascade/migration.sql new file mode 100644 index 0000000000..e170f28b79 --- /dev/null +++ b/services/workflows-service/prisma/migrations/20250202144546_added_document_file_cascade/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "DocumentFile" DROP CONSTRAINT "DocumentFile_documentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DocumentFile" DROP CONSTRAINT "DocumentFile_fileId_fkey"; + +-- AddForeignKey +ALTER TABLE "DocumentFile" ADD CONSTRAINT "DocumentFile_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentFile" ADD CONSTRAINT "DocumentFile_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/services/workflows-service/prisma/schema.prisma b/services/workflows-service/prisma/schema.prisma index d2fd47d408..db324b36af 100644 --- a/services/workflows-service/prisma/schema.prisma +++ b/services/workflows-service/prisma/schema.prisma @@ -97,6 +97,7 @@ model EndUser { project Project @relation(fields: [projectId], references: [id]) WorkflowRuntimeDataToken WorkflowRuntimeDataToken[] Counterparty Counterparty[] + documents Document[] @@unique([projectId, correlationId]) @@index([endUserType]) @@ -148,7 +149,7 @@ model Business { shareholderStructure Json? // Information about the ownership structure, including shareholders and their ownership percentages numberOfEmployees Int? // Number of employees working for the business entity businessPurpose String? // Brief description of the business entity's purpose or main activities - documents Json? // Collection of documents required for the KYB process, e.g., registration documents, financial statements + documents Document[] // Collection of documents required for the KYB process, e.g., registration documents, financial statements avatarUrl String? additionalInfo Json? bankInformation Json? @@ -282,6 +283,7 @@ model WorkflowRuntimeData { childWorkflowsRuntimeData WorkflowRuntimeData[] @relation("ParentChild") WorkflowRuntimeDataToken WorkflowRuntimeDataToken[] alerts Alert[] + documents Document[] projectId String project Project @relation(fields: [projectId], references: [id]) @@ -312,6 +314,8 @@ model File { createdAt DateTime @default(now()) createdBy String @default("SYSTEM") + documentFiles DocumentFile[] + projectId String project Project @relation(fields: [projectId], references: [id]) @@ -419,6 +423,8 @@ model Project { SalesforceIntegration SalesforceIntegration? workflowDefinitions WorkflowDefinition[] uiDefinitions UiDefinition[] + documents Document[] + documentFiles DocumentFile[] WorkflowRuntimeDataToken WorkflowRuntimeDataToken[] TransactionRecord TransactionRecord[] AlertDefinition AlertDefinition[] @@ -955,3 +961,81 @@ model BusinessReport { @@index([type]) @@index([batchId]) } + +model Document { + id String @id @default(cuid()) + + category String + type String + + issuingVersion String + issuingCountry String + version Int + status DocumentStatus + decision DocumentDecision? + properties Json + + files DocumentFile[] + + businessId String? + business Business? @relation(fields: [businessId], references: [id]) + + endUserId String? + endUser EndUser? @relation(fields: [endUserId], references: [id]) + + workflowRuntimeDataId String? + workflowRuntimeData WorkflowRuntimeData? @relation(fields: [workflowRuntimeDataId], references: [id]) + + projectId String + project Project @relation(fields: [projectId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([businessId]) + @@index([endUserId]) + @@index([workflowRuntimeDataId]) +} + +enum DocumentStatus { + provided + unprovided + requested +} + +enum DocumentDecision { + approved + rejected + revisions +} + +enum DocumentFileType { + selfie + document + other +} + +enum DocumentFileVariant { + front + back + other +} + +model DocumentFile { + id String @id @default(cuid()) + type DocumentFileType + variant DocumentFileVariant + page Int + + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + fileId String + file File @relation(fields: [fileId], references: [id], onDelete: Cascade) + + projectId String + project Project @relation(fields: [projectId], references: [id]) + + @@index([documentId]) + @@index([fileId]) +} diff --git a/services/workflows-service/scripts/generate-end-user.ts b/services/workflows-service/scripts/generate-end-user.ts index f3dd26a938..d8ee293e35 100644 --- a/services/workflows-service/scripts/generate-end-user.ts +++ b/services/workflows-service/scripts/generate-end-user.ts @@ -71,10 +71,6 @@ export const generateBusiness = ({ ownershipPercentage: Number(faker.finance.amount(0, 100, 2)), }, ], - documents = { - registrationDocument: faker.system.filePath(), - financialStatement: faker.system.filePath(), - }, workflow, projectId, }: { @@ -98,10 +94,6 @@ export const generateBusiness = ({ name: string; ownershipPercentage: number; }>; - documents?: { - registrationDocument: string; - financialStatement: string; - }; workflow?: { runtimeId?: string; workflowDefinitionId: string; @@ -128,7 +120,6 @@ export const generateBusiness = ({ vatNumber, numberOfEmployees, businessPurpose, - documents: JSON.stringify(documents), shareholderStructure: JSON.stringify(shareholderStructure), project: { connect: { id: projectId } }, approvalState: 'PROCESSING', diff --git a/services/workflows-service/src/app.module.ts b/services/workflows-service/src/app.module.ts index b9966c8509..bf2bb9f863 100644 --- a/services/workflows-service/src/app.module.ts +++ b/services/workflows-service/src/app.module.ts @@ -49,6 +49,7 @@ import { RuleEngineModule } from './rule-engine/rule-engine.module'; import { NotionModule } from '@/notion/notion.module'; import { SecretsManagerModule } from '@/secrets-manager/secrets-manager.module'; import { NoteModule } from '@/note/note.module'; +import { DocumentModule } from './document/document.module'; export const validate = async (config: Record) => { const zodEnvSchema = z @@ -133,6 +134,7 @@ export const validate = async (config: Record) => { RuleEngineModule, NotionModule, SecretsManagerModule, + DocumentModule, ], providers: [ { diff --git a/services/workflows-service/src/business/business.controller.external.ts b/services/workflows-service/src/business/business.controller.external.ts index 7bb70e3b66..0156c889cd 100755 --- a/services/workflows-service/src/business/business.controller.external.ts +++ b/services/workflows-service/src/business/business.controller.external.ts @@ -57,7 +57,6 @@ export class BusinessControllerExternal { countryOfIncorporation: 'US', address: 'addess', industry: 'telecom', - documents: 's', projectId: currentProjectId, }, select: { @@ -126,7 +125,6 @@ export class BusinessControllerExternal { address: data.address, registrationNumber: data.registrationNumber, website: data.website, - documents: data.documents ? JSON.stringify(data.documents) : undefined, shareholderStructure: data.shareholderStructure && data.shareholderStructure.length ? JSON.stringify(data.shareholderStructure) @@ -218,7 +216,6 @@ export class BusinessControllerExternal { { data: { ...restOfData, - documents: documents ? JSON.stringify(documents) : undefined, additionalInfo: additionalInfo ? JSON.stringify(additionalInfo) : undefined, bankInformation: bankInformation ? JSON.stringify(bankInformation) : undefined, address: address ? JSON.stringify(address) : undefined, diff --git a/services/workflows-service/src/business/business.controller.ts b/services/workflows-service/src/business/business.controller.ts index f74b814eaa..2a4fbf4841 100644 --- a/services/workflows-service/src/business/business.controller.ts +++ b/services/workflows-service/src/business/business.controller.ts @@ -40,7 +40,6 @@ export class BusinessControllerExternal { countryOfIncorporation: 'US', address: 'addess', industry: 'telecom', - documents: 's', projectId: currentProjectId, }, select: { diff --git a/services/workflows-service/src/collection-flow/collection-flow-entity.service.ts b/services/workflows-service/src/collection-flow/collection-flow-entity.service.ts new file mode 100644 index 0000000000..434099cd67 --- /dev/null +++ b/services/workflows-service/src/collection-flow/collection-flow-entity.service.ts @@ -0,0 +1,94 @@ +import { EndUserService } from '@/end-user/end-user.service'; +import { PrismaService } from '@/prisma/prisma.service'; +import { TProjectId } from '@/types'; +import { WorkflowService } from '@/workflow/workflow.service'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { BusinessPosition } from '@prisma/client'; +import { EntityCreateDto } from './dto/create-entity-input.dto'; + +@Injectable() +export class CollectionFlowEntityService { + constructor( + protected readonly workflowService: WorkflowService, + protected readonly prismaService: PrismaService, + protected readonly endUserService: EndUserService, + ) {} + + async createEntity( + workflowId: string, + entityType: BusinessPosition, + entity: EntityCreateDto, + projectId: TProjectId, + ) { + return await this.prismaService.$transaction(async transaction => { + const workflowRuntimeData = + await this.workflowService.getWorkflowRuntimeDataByIdAndLockUnscoped({ + id: workflowId, + transaction, + }); + + if (!workflowRuntimeData.businessId) { + throw new BadRequestException( + `Attempted to create a Entity to a parent workflow without a business`, + ); + } + + const endUser = await this.endUserService.create({ + data: { + ...entity, + projectId, + }, + }); + await transaction.endUsersOnBusinesses.create({ + data: { + endUserId: endUser.id, + businessId: workflowRuntimeData.businessId, + position: entityType, + }, + }); + + return { + entityId: endUser.id, + }; + }); + } + + async updateEntity(entityId: string, entity: EntityCreateDto) { + return await this.prismaService.$transaction(async transaction => { + const endUser = await transaction.endUser.update({ + where: { + id: entityId, + }, + data: { + ...entity, + }, + }); + + return { + entityId: endUser.id, + }; + }); + } + + async deleteEntity(entityId: string) { + return await this.prismaService.$transaction(async transaction => { + await transaction.endUsersOnBusinesses.deleteMany({ + where: { + endUserId: entityId, + }, + }); + + await transaction.endUser.delete({ + where: { + id: entityId, + }, + }); + + await transaction.document.deleteMany({ + where: { + endUserId: entityId, + }, + }); + }); + } +} diff --git a/services/workflows-service/src/collection-flow/collection-flow.module.ts b/services/workflows-service/src/collection-flow/collection-flow.module.ts index 6c25998009..63e05d5333 100644 --- a/services/workflows-service/src/collection-flow/collection-flow.module.ts +++ b/services/workflows-service/src/collection-flow/collection-flow.module.ts @@ -8,6 +8,7 @@ import { CollectionFlowBusinessController } from '@/collection-flow/controllers/ import { CollectionFlowController } from '@/collection-flow/controllers/collection-flow.controller'; import { CollectionFlowEndUserController } from '@/collection-flow/controllers/collection-flow.end-user.controller'; import { CollectionFlowFilesController } from '@/collection-flow/controllers/collection-flow.files.controller'; +import { CollectionFlowNoUserController } from '@/collection-flow/controllers/collection-flow.no-user.controller'; import { WorkflowAdapterManager } from '@/collection-flow/workflow-adapter.manager'; import { AppLoggerModule } from '@/common/app-logger/app-logger.module'; import { EntityRepository } from '@/common/entity/entity.repository'; @@ -39,7 +40,9 @@ import { WorkflowRuntimeDataRepository } from '@/workflow/workflow-runtime-data. import { WorkflowModule } from '@/workflow/workflow.module'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { CollectionFlowNoUserController } from '@/collection-flow/controllers/collection-flow.no-user.controller'; +import { CollectionFlowEntityService } from './collection-flow-entity.service'; +import { CollectionFlowEntityController } from './controllers/collection-flow.entity.controller'; +import { DocumentModule } from '@/document/document.module'; @Module({ imports: [ @@ -54,6 +57,7 @@ import { CollectionFlowNoUserController } from '@/collection-flow/controllers/co DataAnalyticsModule, RuleEngineModule, WorkflowModule, + DocumentModule, ], controllers: [ CollectionFlowController, @@ -61,6 +65,7 @@ import { CollectionFlowNoUserController } from '@/collection-flow/controllers/co CollectionFlowNoUserController, CollectionFlowBusinessController, CollectionFlowEndUserController, + CollectionFlowEntityController, ], providers: [ CollectionFlowService, @@ -90,6 +95,7 @@ import { CollectionFlowNoUserController } from '@/collection-flow/controllers/co SalesforceService, SalesforceIntegrationRepository, SentryService, + CollectionFlowEntityService, ], }) export class CollectionFlowModule {} diff --git a/services/workflows-service/src/collection-flow/collection-flow.service.ts b/services/workflows-service/src/collection-flow/collection-flow.service.ts index 2dd8371dac..f3a1131593 100644 --- a/services/workflows-service/src/collection-flow/collection-flow.service.ts +++ b/services/workflows-service/src/collection-flow/collection-flow.service.ts @@ -7,7 +7,6 @@ import { type ITokenScope } from '@/common/decorators/token-scope.decorator'; import { CustomerService } from '@/customer/customer.service'; import { TCustomerWithFeatures } from '@/customer/types'; import { EndUserService } from '@/end-user/end-user.service'; -import { NotFoundException } from '@/errors'; import { FileService } from '@/providers/file/file.service'; import { TranslationService } from '@/providers/translation/translation.service'; import type { TProjectId, TProjectIds } from '@/types'; @@ -18,7 +17,6 @@ import { DefaultContextSchema, TCollectionFlowConfig } from '@ballerine/common'; import { BUILT_IN_EVENT } from '@ballerine/workflow-core'; import { Injectable } from '@nestjs/common'; import { EndUser, Prisma, WorkflowRuntimeData } from '@prisma/client'; -import { randomUUID } from 'crypto'; @Injectable() export class CollectionFlowService { @@ -207,41 +205,6 @@ export class CollectionFlowService { ); } - async uploadNewFile(projectId: string, workflowRuntimeDataId: string, file: Express.Multer.File) { - // upload file into a customer folder - const customer = await this.customerService.getByProjectId(projectId); - - const runtimeDataId = await this.workflowService.getWorkflowRuntimeDataById( - workflowRuntimeDataId, - {}, - [projectId], - ); - - const entityId = runtimeDataId.businessId || runtimeDataId.endUserId; - - if (!entityId) { - throw new NotFoundException("Workflow doesn't exists"); - } - - // Remove file extension (get everything before the last dot) - const nameWithoutExtension = (file.originalname || randomUUID()).replace(/\.[^.]+$/, ''); - // Remove non characters - const alphabeticOnlyName = nameWithoutExtension.replace(/\W/g, ''); - - return await this.fileService.copyToDestinationAndCreate( - { - id: alphabeticOnlyName, - uri: file.path, - provider: 'file-system', - fileName: file.originalname, - }, - entityId, - projectId, - customer.name, - { shouldDownloadFromSource: false }, - ); - } - async getCollectionFlowContext( tokenScope: ITokenScope, ): Promise<{ context: DefaultContextSchema; config: TCollectionFlowConfig }> { diff --git a/services/workflows-service/src/collection-flow/controllers/collection-flow.entity.controller.ts b/services/workflows-service/src/collection-flow/controllers/collection-flow.entity.controller.ts new file mode 100644 index 0000000000..a602f8cdb1 --- /dev/null +++ b/services/workflows-service/src/collection-flow/controllers/collection-flow.entity.controller.ts @@ -0,0 +1,35 @@ +import { TokenScope, type ITokenScope } from '@/common/decorators/token-scope.decorator'; +import { UseTokenAuthGuard } from '@/common/guards/token-guard/use-token-auth.decorator'; +import { Body, Controller, Delete, Param, Post, Put } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { CollectionFlowEntityService } from '../collection-flow-entity.service'; +import { CreateEntityInputDto, EntityCreateDto } from '../dto/create-entity-input.dto'; + +@UseTokenAuthGuard() +@ApiExcludeController() +@Controller('collection-flow/entity') +export class CollectionFlowEntityController { + constructor(private readonly collectionFlowEntityService: CollectionFlowEntityService) {} + + @Post() + async createEntity(@TokenScope() tokenScope: ITokenScope, @Body() body: CreateEntityInputDto) { + const { entityType, entity } = body; + + return this.collectionFlowEntityService.createEntity( + tokenScope.workflowRuntimeDataId, + entityType, + entity, + tokenScope.projectId, + ); + } + + @Put(':entityId') + async updateEntity(@Param('entityId') entityId: string, @Body() body: EntityCreateDto) { + return this.collectionFlowEntityService.updateEntity(entityId, body); + } + + @Delete(':entityId') + async deleteEntity(@Param('entityId') entityId: string) { + return this.collectionFlowEntityService.deleteEntity(entityId); + } +} diff --git a/services/workflows-service/src/collection-flow/controllers/collection-flow.files.controller.ts b/services/workflows-service/src/collection-flow/controllers/collection-flow.files.controller.ts index 9510a3d089..d0b5932855 100644 --- a/services/workflows-service/src/collection-flow/controllers/collection-flow.files.controller.ts +++ b/services/workflows-service/src/collection-flow/controllers/collection-flow.files.controller.ts @@ -1,13 +1,19 @@ import { CollectionFlowService } from '@/collection-flow/collection-flow.service'; import { TokenScope, type ITokenScope } from '@/common/decorators/token-scope.decorator'; -import { getFileMetadata } from '@/common/get-file-metadata/get-file-metadata'; import { UseTokenAuthGuard } from '@/common/guards/token-guard/use-token-auth.decorator'; import { RemoveTempFileInterceptor } from '@/common/interceptors/remove-temp-file.interceptor'; +import { DocumentFileJsonSchema } from '@/document-file/dtos/document-file.dto'; +import { DocumentService } from '@/document/document.service'; +import { CreateDocumentSchema, DeleteDocumentsSchema } from '@/document/dtos/document.dto'; +import { FileService } from '@/providers/file/file.service'; import { FILE_MAX_SIZE_IN_BYTE, FILE_SIZE_EXCEEDED_MSG, fileFilter } from '@/storage/file-filter'; import { getDiskStorage } from '@/storage/get-file-storage-manager'; import { StorageService } from '@/storage/storage.service'; +import { WorkflowService } from '@/workflow/workflow.service'; import { + Body, Controller, + Delete, Get, Param, ParseFilePipeBuilder, @@ -18,8 +24,10 @@ import { UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiExcludeController } from '@nestjs/swagger'; +import { ApiExcludeController, ApiResponse } from '@nestjs/swagger'; +import { Type, type Static } from '@sinclair/typebox'; import type { Response } from 'express'; +import z from 'zod'; import * as errors from '../../errors'; @UseTokenAuthGuard() @@ -29,9 +37,11 @@ export class CollectionFlowFilesController { constructor( protected readonly storageService: StorageService, protected readonly collectionFlowService: CollectionFlowService, + protected readonly fileService: FileService, + protected readonly workflowService: WorkflowService, + protected readonly documentService: DocumentService, ) {} - // curl -v -F "file=@//a.jpg" http://localhost:3000/api/v1/collection-flow/files @UseInterceptors( FileInterceptor('file', { storage: getDiskStorage(), @@ -42,8 +52,19 @@ export class CollectionFlowFilesController { }), RemoveTempFileInterceptor, ) - @Post('') - async uploadFile( + @Post() + @ApiResponse({ + status: 200, + description: 'Document created successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + async createDocument( + @TokenScope() tokenScope: ITokenScope, + @Body() + data: Omit, 'properties'> & { + metadata: string; + properties: string; + }, @UploadedFile( new ParseFilePipeBuilder().addMaxSizeValidator({ maxSize: FILE_MAX_SIZE_IN_BYTE }).build({ fileIsRequired: true, @@ -57,24 +78,46 @@ export class CollectionFlowFilesController { }), ) file: Express.Multer.File, + ) { + const metadata = DocumentFileJsonSchema.parse(data.metadata); + const properties = z + .preprocess(value => { + if (typeof value !== 'string') { + return value; + } + + return JSON.parse(value); + }, z.record(z.string(), z.unknown())) + .parse(data.properties); + + data.workflowRuntimeDataId = tokenScope.workflowRuntimeDataId; + + // FormData returns version as a string + // Manually converting to number to avoid validation errors + data.version = Number(data.version); + + const documentsCreationResults = await this.documentService.create({ + ...data, + properties, + metadata, + file, + projectId: tokenScope.projectId, + }); + + return documentsCreationResults[0]; + } + + @Delete() + @ApiResponse({ + status: 200, + description: 'Documents deleted successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + async deleteDocumentsByIds( @TokenScope() tokenScope: ITokenScope, + @Body() { ids }: Static, ) { - return this.collectionFlowService.uploadNewFile( - tokenScope.projectId, - tokenScope.workflowRuntimeDataId, - { - ...file, - mimetype: - file.mimetype || - ( - await getFileMetadata({ - file: file.originalname || '', - fileName: file.originalname || '', - }) - )?.mimeType || - '', - }, - ); + return await this.documentService.deleteByIds(ids, [tokenScope.projectId]); } @Get('/:id') diff --git a/services/workflows-service/src/collection-flow/dto/create-entity-input.dto.ts b/services/workflows-service/src/collection-flow/dto/create-entity-input.dto.ts new file mode 100644 index 0000000000..d9759d4922 --- /dev/null +++ b/services/workflows-service/src/collection-flow/dto/create-entity-input.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BusinessPosition } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; + +export class EntityCreateDto { + @ApiProperty({ + required: true, + type: String, + }) + @IsString() + firstName!: string; + + @ApiProperty({ + required: true, + type: String, + }) + @IsString() + lastName!: string; + + @IsOptional() + @ApiProperty({ + type: String, + }) + @IsString() + email?: string; + + @IsOptional() + @ApiProperty({ + type: Boolean, + }) + @IsBoolean() + isContactPerson?: boolean; + + @IsOptional() + @ApiProperty({ + type: String, + }) + @IsString() + phone?: string; + + @IsOptional() + @ApiProperty({ + type: String, + }) + @IsString() + country?: string; + + @IsOptional() + @ApiProperty({ + type: String, + }) + @IsString() + dateOfBirth?: string; + + @IsOptional() + @IsObject() + additionalInfo?: Record; +} + +export class CreateEntityInputDto { + @IsString() + entityType!: BusinessPosition; + + @IsObject() + @ValidateNested() + @Type(() => EntityCreateDto) + entity!: EntityCreateDto; +} diff --git a/services/workflows-service/src/document-file/document-file.module.ts b/services/workflows-service/src/document-file/document-file.module.ts new file mode 100644 index 0000000000..d4cd96bcd2 --- /dev/null +++ b/services/workflows-service/src/document-file/document-file.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DocumentFileService } from './document-file.service'; +import { DocumentFileRepository } from './document-file.repository'; +import { PrismaModule } from '@/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [DocumentFileService, DocumentFileRepository], + exports: [DocumentFileService], +}) +export class DocumentFileModule {} diff --git a/services/workflows-service/src/document-file/document-file.repository.ts b/services/workflows-service/src/document-file/document-file.repository.ts new file mode 100644 index 0000000000..ab1e26e661 --- /dev/null +++ b/services/workflows-service/src/document-file/document-file.repository.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { Prisma } from '@prisma/client'; +import { PrismaTransactionClient, TProjectId } from '@/types'; + +@Injectable() +export class DocumentFileRepository { + constructor(protected readonly prismaService: PrismaService) {} + + async create( + data: Prisma.DocumentFileUncheckedCreateInput, + args?: Prisma.DocumentFileCreateArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.documentFile.create({ + ...args, + data, + }); + } + + async createMany( + data: Prisma.Enumerable, + args?: Prisma.DocumentFileCreateManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.documentFile.createMany({ + ...args, + data, + }); + } + + async findByDocumentId( + documentId: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFileFindManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.documentFile.findMany({ + ...args, + where: { + ...args?.where, + documentId, + projectId: { in: projectIds }, + }, + }); + } + + async updateById( + id: string, + data: Prisma.DocumentFileUpdateInput, + args?: Prisma.DocumentFileUpdateArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.documentFile.update({ + ...args, + where: { + ...args?.where, + id, + }, + data, + }); + } + + async deleteById( + id: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFileDeleteManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.documentFile.deleteMany({ + ...args, + where: { + ...args?.where, + id, + projectId: { in: projectIds }, + }, + }); + } + + async deleteByDocumentId( + documentId: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFileDeleteManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.documentFile.deleteMany({ + ...args, + where: { + ...args?.where, + documentId, + projectId: { in: projectIds }, + }, + }); + } +} diff --git a/services/workflows-service/src/document-file/document-file.service.ts b/services/workflows-service/src/document-file/document-file.service.ts new file mode 100644 index 0000000000..8b19f3dc94 --- /dev/null +++ b/services/workflows-service/src/document-file/document-file.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentFileRepository } from './document-file.repository'; +import { Prisma } from '@prisma/client'; +import { PrismaTransactionClient, TProjectId } from '@/types'; + +@Injectable() +export class DocumentFileService { + constructor(protected readonly repository: DocumentFileRepository) {} + + async create( + data: Prisma.DocumentFileUncheckedCreateInput, + args?: Prisma.DocumentFileCreateArgs, + transaction?: PrismaTransactionClient, + ) { + return await this.repository.create(data, args, transaction); + } + + async createMany( + data: Prisma.Enumerable, + args?: Prisma.DocumentFileCreateManyArgs, + transaction?: PrismaTransactionClient, + ) { + return await this.repository.createMany(data, args, transaction); + } + + async getByDocumentId( + documentId: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFileFindManyArgs, + transaction?: PrismaTransactionClient, + ) { + return await this.repository.findByDocumentId(documentId, projectIds, args, transaction); + } + + async updateById( + id: string, + data: Prisma.DocumentFileUpdateInput, + args?: Prisma.DocumentFileUpdateArgs, + transaction?: PrismaTransactionClient, + ) { + return await this.repository.updateById(id, data, args, transaction); + } + + async deleteById( + id: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFileDeleteArgs, + transaction?: PrismaTransactionClient, + ) { + return await this.repository.deleteById(id, projectIds, args, transaction); + } + + async deleteByDocumentId( + documentId: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFileDeleteManyArgs, + transaction?: PrismaTransactionClient, + ) { + return await this.repository.deleteByDocumentId(documentId, projectIds, args, transaction); + } +} diff --git a/services/workflows-service/src/document-file/dtos/document-file.dto.ts b/services/workflows-service/src/document-file/dtos/document-file.dto.ts new file mode 100644 index 0000000000..3940a535f5 --- /dev/null +++ b/services/workflows-service/src/document-file/dtos/document-file.dto.ts @@ -0,0 +1,34 @@ +import { Type } from '@sinclair/typebox'; +import { DocumentFileType, DocumentFileVariant } from '@prisma/client'; +import * as z from 'zod'; + +export const DocumentFileSchema = Type.Object({ + id: Type.String(), + type: Type.Enum(DocumentFileType), + variant: Type.Enum(DocumentFileVariant), + page: Type.Integer(), + documentId: Type.String(), + fileId: Type.String(), + projectId: Type.String(), +}); + +export const DocumentFileJsonSchema = z + .string() + .transform(value => JSON.parse(value)) + .pipe( + z.object({ + type: z.nativeEnum(DocumentFileType), + variant: z.nativeEnum(DocumentFileVariant), + page: z.number().positive().int(), + }), + ); + +export const CreateDocumentFileSchema = Type.Omit(DocumentFileSchema, ['id']); + +export const UpdateDocumentFileSchema = Type.Partial( + Type.Omit(DocumentFileSchema, ['id', 'documentId', 'projectId']), +); + +export const DocumentFileParamsSchema = Type.Object({ + id: Type.String(), +}); diff --git a/services/workflows-service/src/document/document.controller.external.ts b/services/workflows-service/src/document/document.controller.external.ts new file mode 100644 index 0000000000..8f3a412e88 --- /dev/null +++ b/services/workflows-service/src/document/document.controller.external.ts @@ -0,0 +1,246 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseFilePipeBuilder, + Patch, + Post, + UnprocessableEntityException, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { DocumentService } from './document.service'; +import { + CreateDocumentSchema, + DeleteDocumentsSchema, + UpdateDocumentSchema, +} from './dtos/document.dto'; +import { Validate } from 'ballerine-nestjs-typebox'; +import { type Static, Type } from '@sinclair/typebox'; +import { CurrentProject } from '@/common/decorators/current-project.decorator'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { RemoveTempFileInterceptor } from '@/common/interceptors/remove-temp-file.interceptor'; +import { getDiskStorage } from '@/storage/get-file-storage-manager'; +import { FILE_MAX_SIZE_IN_BYTE, FILE_SIZE_EXCEEDED_MSG, fileFilter } from '@/storage/file-filter'; +import { DocumentFileJsonSchema } from '@/document-file/dtos/document-file.dto'; +import z from 'zod'; + +@ApiBearerAuth() +@ApiTags('Documents') +@Controller('external/documents') +export class DocumentControllerExternal { + constructor(protected readonly documentService: DocumentService) {} + + @UseInterceptors( + FileInterceptor('file', { + storage: getDiskStorage(), + limits: { + files: 1, + }, + fileFilter, + }), + RemoveTempFileInterceptor, + ) + @Post() + @ApiResponse({ + status: 200, + description: 'Document created successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + @Validate({ + request: [ + { + type: 'body', + schema: Type.Composite([ + Type.Omit(CreateDocumentSchema, ['properties']), + Type.Object({ + metadata: Type.String(), + properties: Type.String(), + }), + ]), + }, + ], + response: Type.Any(), + }) + async createDocument( + @Body() + data: Omit, 'properties'> & { + metadata: string; + properties: string; + }, + @UploadedFile( + new ParseFilePipeBuilder().addMaxSizeValidator({ maxSize: FILE_MAX_SIZE_IN_BYTE }).build({ + fileIsRequired: true, + exceptionFactory: (error: string) => { + if (error.includes('expected size')) { + throw new UnprocessableEntityException(FILE_SIZE_EXCEEDED_MSG); + } + + throw new UnprocessableEntityException(error); + }, + }), + ) + file: Express.Multer.File, + @CurrentProject() projectId: string, + ) { + const metadata = DocumentFileJsonSchema.parse(data.metadata); + const properties = z + .preprocess(value => { + if (typeof value !== 'string') { + return value; + } + + return JSON.parse(value); + }, z.record(z.string(), z.unknown())) + .parse(data.properties); + + return await this.documentService.create({ + ...data, + properties, + metadata, + file, + projectId, + }); + } + + @Get('/:entityId/:workflowRuntimeDataId') + @ApiResponse({ + status: 200, + description: 'Documents retrieved successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + @Validate({ + request: [ + { + type: 'param', + name: 'entityId', + schema: Type.String(), + }, + { + type: 'param', + name: 'workflowRuntimeDataId', + schema: Type.String(), + }, + ], + response: Type.Any(), + }) + async getDocumentsByEntityIdAndWorkflowId( + @Param('entityId') entityId: string, + @Param('workflowRuntimeDataId') workflowRuntimeDataId: string, + @CurrentProject() projectId: string, + ) { + return await this.documentService.getByEntityIdAndWorkflowId(entityId, workflowRuntimeDataId, [ + projectId, + ]); + } + + @Patch('/:documentId') + @ApiResponse({ + status: 200, + description: 'Document updated successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + @Validate({ + request: [ + { + type: 'param', + name: 'documentId', + schema: Type.String(), + }, + { + type: 'body', + schema: UpdateDocumentSchema, + }, + ], + response: Type.Any(), + }) + async updateDocumentById( + @Param('documentId') documentId: string, + @Body() data: Static, + @CurrentProject() projectId: string, + ) { + return await this.documentService.updateById(documentId, [projectId], data); + } + + @UseInterceptors( + FileInterceptor('file', { + storage: getDiskStorage(), + limits: { + files: 1, + }, + fileFilter, + }), + RemoveTempFileInterceptor, + ) + @Post('/:workflowRuntimeDataId/:fileId') + @ApiResponse({ + status: 200, + description: 'Document reuploaded successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + @Validate({ + request: [ + { + type: 'param', + name: 'workflowRuntimeDataId', + schema: Type.String(), + }, + { + type: 'param', + name: 'fileId', + schema: Type.String(), + }, + ], + response: Type.Any(), + }) + async reuploadDocumentFileById( + @Param('workflowRuntimeDataId') workflowRuntimeDataId: string, + @Param('fileId') fileId: string, + @UploadedFile( + new ParseFilePipeBuilder().addMaxSizeValidator({ maxSize: FILE_MAX_SIZE_IN_BYTE }).build({ + fileIsRequired: true, + exceptionFactory: (error: string) => { + if (error.includes('expected size')) { + throw new UnprocessableEntityException(FILE_SIZE_EXCEEDED_MSG); + } + + throw new UnprocessableEntityException(error); + }, + }), + ) + file: Express.Multer.File, + @CurrentProject() projectId: string, + ) { + return await this.documentService.reuploadDocumentFileById( + fileId, + workflowRuntimeDataId, + [projectId], + file, + ); + } + + @Delete() + @ApiResponse({ + status: 200, + description: 'Documents deleted successfully', + schema: Type.Array(Type.Record(Type.String(), Type.Any())), + }) + @Validate({ + request: [ + { + type: 'body', + schema: DeleteDocumentsSchema, + }, + ], + response: Type.Any(), + }) + async deleteDocumentsByIds( + @Body() { ids }: Static, + @CurrentProject() projectId: string, + ) { + return await this.documentService.deleteByIds(ids, [projectId]); + } +} diff --git a/services/workflows-service/src/document/document.module.ts b/services/workflows-service/src/document/document.module.ts new file mode 100644 index 0000000000..06d32c4b49 --- /dev/null +++ b/services/workflows-service/src/document/document.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { DocumentService } from './document.service'; +import { DocumentRepository } from './document.repository'; +import { DocumentControllerExternal } from './document.controller.external'; +import { PrismaModule } from '@/prisma/prisma.module'; +import { DocumentFileModule } from '@/document-file/document-file.module'; +import { FileModule } from '@/providers/file/file.module'; +import { WorkflowModule } from '@/workflow/workflow.module'; + +@Module({ + imports: [PrismaModule, DocumentFileModule, FileModule, WorkflowModule], + controllers: [DocumentControllerExternal], + providers: [DocumentService, DocumentRepository], + exports: [DocumentService], +}) +export class DocumentModule {} diff --git a/services/workflows-service/src/document/document.repository.ts b/services/workflows-service/src/document/document.repository.ts new file mode 100644 index 0000000000..768b8e739e --- /dev/null +++ b/services/workflows-service/src/document/document.repository.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/prisma/prisma.service'; +import { Prisma } from '@prisma/client'; +import { PrismaTransactionClient, TProjectId } from '@/types'; + +@Injectable() +export class DocumentRepository { + constructor(protected readonly prismaService: PrismaService) {} + + async create( + data: Prisma.DocumentUncheckedCreateInput, + args?: Prisma.DocumentCreateArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.document.create({ + ...args, + data, + }); + } + + async findMany( + projectIds: TProjectId[], + args?: Prisma.DocumentFindManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return await transaction.document.findMany({ + ...args, + where: { + ...args?.where, + projectId: { in: projectIds }, + }, + }); + } + + async findByEntityIdAndWorkflowId( + entityId: string, + workflowRuntimeDataId: string, + projectIds: TProjectId[], + args?: Prisma.DocumentFindManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return transaction.document.findMany({ + ...args, + where: { + ...args?.where, + OR: [{ businessId: entityId }, { endUserId: entityId }], + workflowRuntimeDataId, + projectId: { in: projectIds }, + }, + }); + } + + async updateById( + id: string, + projectIds: TProjectId[], + data: Prisma.DocumentUpdateInput, + args?: Prisma.DocumentUpdateManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return await transaction.document.updateMany({ + ...args, + where: { + ...args?.where, + id, + projectId: { in: projectIds }, + }, + data, + }); + } + + async deleteByIds( + ids: string[], + projectIds: TProjectId[], + args?: Prisma.DocumentDeleteManyArgs, + transaction: PrismaTransactionClient = this.prismaService, + ) { + return await transaction.document.deleteMany({ + ...args, + where: { + ...args?.where, + id: { in: ids }, + projectId: { in: projectIds }, + }, + }); + } +} diff --git a/services/workflows-service/src/document/document.service.ts b/services/workflows-service/src/document/document.service.ts new file mode 100644 index 0000000000..7569c78868 --- /dev/null +++ b/services/workflows-service/src/document/document.service.ts @@ -0,0 +1,267 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DocumentRepository } from './document.repository'; +import { Document, DocumentFile, Prisma } from '@prisma/client'; +import { PrismaTransactionClient, TProjectId } from '@/types'; +import { DocumentFileService } from '@/document-file/document-file.service'; +import { StorageService } from '@/storage/storage.service'; +import { FileService } from '@/providers/file/file.service'; +import { getFileMetadata } from '@/common/get-file-metadata/get-file-metadata'; +import { Static } from '@sinclair/typebox'; +import { CreateDocumentSchema } from './dtos/document.dto'; +import { CreateDocumentFileSchema } from '@/document-file/dtos/document-file.dto'; +import { WorkflowService } from '@/workflow/workflow.service'; + +@Injectable() +export class DocumentService { + constructor( + protected readonly repository: DocumentRepository, + protected readonly documentFileService: DocumentFileService, + protected readonly fileService: FileService, + protected readonly workflowService: WorkflowService, + protected readonly storageService: StorageService, + ) {} + + async create( + { + file, + metadata, + projectId, + ...data + }: Static & { + file: Express.Multer.File; + metadata: Omit< + Static, + 'documentId' | 'fileId' | 'projectId' + >; + projectId: string; + }, + args?: Prisma.DocumentCreateArgs, + transaction?: PrismaTransactionClient, + ) { + if (!data.businessId && !data.endUserId) { + throw new BadRequestException('Business or end user id is required'); + } + + if (data.businessId && data.endUserId) { + throw new BadRequestException('Business and end user id cannot be set at the same time'); + } + + if (!data.workflowRuntimeDataId) { + throw new BadRequestException('Workflow runtime data id is required'); + } + + const getEntityId = () => { + if (data.businessId) { + return data.businessId; + } + + if (data.endUserId) { + return data.endUserId; + } + + throw new BadRequestException('Business or end user id is required'); + }; + + const workflowRuntimeData = await this.workflowService.getWorkflowRuntimeDataById( + data.workflowRuntimeDataId, + {}, + [projectId], + ); + + const uploadedFile = await this.fileService.uploadNewFile(projectId, workflowRuntimeData, { + ...file, + mimetype: + file.mimetype || + ( + await getFileMetadata({ + file: file.originalname || '', + fileName: file.originalname || '', + }) + )?.mimeType || + '', + }); + const document = await this.repository.create( + { + ...data, + ...(data.businessId && { businessId: data.businessId }), + ...(data.endUserId && { endUserId: data.endUserId }), + projectId, + }, + args, + transaction, + ); + + await this.documentFileService.create( + { + documentId: document.id, + fileId: uploadedFile.id, + projectId, + ...metadata, + }, + undefined, + transaction, + ); + + const entityId = getEntityId(); + + return await this.getByEntityIdAndWorkflowId(entityId, data.workflowRuntimeDataId, [projectId]); + } + + async getByEntityIdAndWorkflowId( + entityId: string, + workflowRuntimeDataId: string, + projectIds: TProjectId[], + args?: Omit, + transaction?: PrismaTransactionClient, + ) { + const documents = await this.repository.findByEntityIdAndWorkflowId( + entityId, + workflowRuntimeDataId, + projectIds, + { + ...args, + include: { + ...args?.include, + files: true, + }, + }, + transaction, + ); + const documentsWithFiles = await this.fetchDocumentsFiles({ + documents: documents as Array, + format: 'signed-url', + }); + + return documentsWithFiles; + } + + async updateById( + id: string, + projectIds: TProjectId[], + data: Prisma.DocumentUpdateInput, + args?: Prisma.DocumentUpdateManyArgs, + transaction?: PrismaTransactionClient, + ) { + await this.repository.updateById(id, projectIds, data, args, transaction); + + const documents = await this.repository.findMany( + projectIds, + { + include: { + files: true, + }, + }, + transaction, + ); + const documentsWithFiles = await this.fetchDocumentsFiles({ + documents: documents as Array, + format: 'signed-url', + }); + + return documentsWithFiles; + } + + async deleteByIds( + ids: string[], + projectIds: TProjectId[], + args?: Prisma.DocumentDeleteManyArgs, + transaction?: PrismaTransactionClient, + ) { + await this.repository.deleteByIds(ids, projectIds, args, transaction); + + const documents = await this.repository.findMany( + projectIds, + { + include: { + files: true, + }, + }, + transaction, + ); + const documentsWithFiles = await this.fetchDocumentsFiles({ + documents: documents as Array, + format: 'signed-url', + }); + + return documentsWithFiles; + } + + async fetchDocumentsFiles({ + documents, + format, + }: { + documents: Array; + format: Parameters[0]['format']; + }) { + return await Promise.all( + documents?.map(async document => { + const files = await Promise.all( + document.files?.map(async file => { + const uploadedFile = await this.storageService.fetchFileContent({ + id: file.fileId, + projectIds: [document.projectId], + format, + }); + + return { + ...file, + mimeType: uploadedFile.mimeType, + signedUrl: uploadedFile.signedUrl, + }; + }) ?? [], + ); + + return { + ...document, + files, + }; + }) ?? [], + ); + } + + async reuploadDocumentFileById( + fileId: string, + workflowRuntimeDataId: string, + projectIds: TProjectId[], + file: Express.Multer.File, + ) { + if (!projectIds[0]) { + throw new BadRequestException('Project id is required'); + } + + const workflowRuntimeData = await this.workflowService.getWorkflowRuntimeDataById( + workflowRuntimeDataId, + {}, + projectIds, + ); + const uploadedFile = await this.fileService.uploadNewFile(projectIds[0], workflowRuntimeData, { + ...file, + mimetype: + file.mimetype || + ( + await getFileMetadata({ + file: file.originalname || '', + fileName: file.originalname || '', + }) + )?.mimeType || + '', + }); + + await this.documentFileService.updateById(fileId, { + file: { + connect: { id: uploadedFile.id }, + }, + }); + + const documents = await this.repository.findMany(projectIds, { + include: { + files: true, + }, + }); + + return await this.fetchDocumentsFiles({ + documents: documents as Array, + format: 'signed-url', + }); + } +} diff --git a/services/workflows-service/src/document/dtos/document.dto.ts b/services/workflows-service/src/document/dtos/document.dto.ts new file mode 100644 index 0000000000..ff2560f5c0 --- /dev/null +++ b/services/workflows-service/src/document/dtos/document.dto.ts @@ -0,0 +1,26 @@ +import { DocumentDecision, DocumentStatus } from '@prisma/client'; +import { Type } from '@sinclair/typebox'; + +export const DocumentSchema = Type.Object({ + id: Type.String(), + category: Type.String(), + type: Type.String(), + issuingVersion: Type.String(), + issuingCountry: Type.String(), + version: Type.Integer(), + status: Type.Enum(DocumentStatus), + decision: Type.Optional(Type.Enum(DocumentDecision)), + properties: Type.Record(Type.String(), Type.Any()), + businessId: Type.Optional(Type.String()), + endUserId: Type.Optional(Type.String()), + workflowRuntimeDataId: Type.Optional(Type.String()), + projectId: Type.String(), +}); + +export const CreateDocumentSchema = Type.Omit(DocumentSchema, ['id', 'projectId']); + +export const UpdateDocumentSchema = Type.Partial(DocumentSchema); + +export const DeleteDocumentsSchema = Type.Object({ + ids: Type.Array(Type.String()), +}); diff --git a/services/workflows-service/src/end-user/dtos/end-user-create.ts b/services/workflows-service/src/end-user/dtos/end-user-create.ts index 9137ce1811..049037b807 100644 --- a/services/workflows-service/src/end-user/dtos/end-user-create.ts +++ b/services/workflows-service/src/end-user/dtos/end-user-create.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; export class EndUserCreateDto { @ApiProperty({ @@ -43,6 +43,13 @@ export class EndUserCreateDto { @IsString() phone?: string; + @IsOptional() + @ApiProperty({ + type: String, + }) + @IsString() + country?: string; + @IsOptional() @ApiProperty({ type: String, @@ -56,4 +63,8 @@ export class EndUserCreateDto { }) @IsString() avatarUrl?: string; + + @IsOptional() + @IsObject() + additionalInfo?: Record; } diff --git a/services/workflows-service/src/providers/file/file-service.module.ts b/services/workflows-service/src/providers/file/file-service.module.ts deleted file mode 100644 index c3013cc836..0000000000 --- a/services/workflows-service/src/providers/file/file-service.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FileService } from '@/providers/file/file.service'; -import { WorkflowControllerExternal } from '@/workflow/workflow.controller.external'; -import { HttpModule } from '@nestjs/axios'; - -@Module({ - imports: [ - HttpModule, // TODO: register with config and set retry mechanisem for http calls - ], - controllers: [WorkflowControllerExternal], - providers: [FileService], - exports: [FileService], -}) -export class FileServiceModule {} diff --git a/services/workflows-service/src/providers/file/file.module.ts b/services/workflows-service/src/providers/file/file.module.ts new file mode 100644 index 0000000000..76a02137c1 --- /dev/null +++ b/services/workflows-service/src/providers/file/file.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { FileService } from '@/providers/file/file.service'; +import { HttpModule } from '@nestjs/axios'; +import { FileRepository } from '@/storage/storage.repository'; +import { StorageService } from '@/storage/storage.service'; +import { CustomerService } from '@/customer/customer.service'; +import { ProjectModule } from '@/project/project.module'; +import { CustomerModule } from '@/customer/customer.module'; + +@Module({ + imports: [ + HttpModule, // TODO: register with config and set retry mechanisem for http calls + ProjectModule, + CustomerModule, + ], + controllers: [], + providers: [FileService, FileRepository, StorageService, CustomerService], + exports: [FileService], +}) +export class FileModule {} diff --git a/services/workflows-service/src/providers/file/file-service.service.test.skip.ts b/services/workflows-service/src/providers/file/file.service.test.skip.ts similarity index 100% rename from services/workflows-service/src/providers/file/file-service.service.test.skip.ts rename to services/workflows-service/src/providers/file/file.service.test.skip.ts diff --git a/services/workflows-service/src/providers/file/file.service.ts b/services/workflows-service/src/providers/file/file.service.ts index 7ff033b470..2439962084 100644 --- a/services/workflows-service/src/providers/file/file.service.ts +++ b/services/workflows-service/src/providers/file/file.service.ts @@ -10,7 +10,7 @@ import { StorageService } from '@/storage/storage.service'; import type { TProjectId } from '@/types'; import { getDocumentId, isErrorWithMessage, isType } from '@ballerine/common'; import { HttpService } from '@nestjs/axios'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'crypto'; import * as fs from 'fs/promises'; import { Base64 } from 'js-base64'; @@ -20,6 +20,8 @@ import { z } from 'zod'; import { TFileServiceProvider } from './types'; import { TLocalFilePath, TRemoteFileConfig, TS3BucketConfig } from './types/files-types'; import { IStreamableFileProvider } from './types/interfaces'; +import { CustomerService } from '@/customer/customer.service'; +import { WorkflowRuntimeData } from '@prisma/client'; @Injectable() export class FileService { @@ -27,6 +29,7 @@ export class FileService { private readonly storageService: StorageService, protected readonly logger: AppLoggerService, protected readonly httpService: HttpService, + protected readonly customerService: CustomerService, ) {} async copyFromSourceToDestination( @@ -355,4 +358,37 @@ export class FileService { return persistedFile; } + + async uploadNewFile( + projectId: string, + workflowRuntimeData: WorkflowRuntimeData, + file: Express.Multer.File, + ) { + // upload file into a customer folder + const customer = await this.customerService.getByProjectId(projectId); + + const entityId = workflowRuntimeData.businessId || workflowRuntimeData.endUserId; + + if (!entityId) { + throw new NotFoundException("Workflow doesn't exists"); + } + + // Remove file extension (get everything before the last dot) + const nameWithoutExtension = (file.originalname || randomUUID()).replace(/\.[^.]+$/, ''); + // Remove non characters + const alphabeticOnlyName = nameWithoutExtension.replace(/\W/g, ''); + + return await this.copyToDestinationAndCreate( + { + id: alphabeticOnlyName, + uri: file.path, + provider: 'file-system', + fileName: file.originalname, + }, + entityId, + projectId, + customer.name, + { shouldDownloadFromSource: false }, + ); + } } diff --git a/services/workflows-service/src/storage/storage.module.ts b/services/workflows-service/src/storage/storage.module.ts index 1aadbb05b3..4a3bc70ce2 100644 --- a/services/workflows-service/src/storage/storage.module.ts +++ b/services/workflows-service/src/storage/storage.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { StorageControllerExternal } from './storage.controller.external'; import { StorageControllerInternal } from './storage.controller.internal'; import { FileRepository } from './storage.repository'; @@ -6,9 +6,10 @@ import { StorageService } from './storage.service'; import { ProjectModule } from '@/project/project.module'; import { CustomerModule } from '@/customer/customer.module'; import { HttpModule } from '@nestjs/axios'; +import { FileModule } from '@/providers/file/file.module'; @Module({ - imports: [ProjectModule, CustomerModule, HttpModule], + imports: [ProjectModule, CustomerModule, HttpModule, forwardRef(() => FileModule)], controllers: [StorageControllerInternal, StorageControllerExternal], providers: [StorageService, FileRepository], exports: [StorageService], diff --git a/services/workflows-service/src/storage/storage.service.ts b/services/workflows-service/src/storage/storage.service.ts index 2c01648fa8..2167b2879e 100644 --- a/services/workflows-service/src/storage/storage.service.ts +++ b/services/workflows-service/src/storage/storage.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { FileRepository } from './storage.repository'; import { IFileIds } from './types'; import { File, Prisma } from '@prisma/client'; @@ -18,6 +18,7 @@ import { z } from 'zod'; import { HttpService } from '@nestjs/axios'; import { AppLoggerService } from '@/common/app-logger/app-logger.service'; import { readFileSync } from 'fs'; +import { randomUUID } from 'crypto'; @Injectable() export class StorageService { diff --git a/services/workflows-service/src/workflow/workflow.module.ts b/services/workflows-service/src/workflow/workflow.module.ts index 3e47acdd98..d9d5717726 100644 --- a/services/workflows-service/src/workflow/workflow.module.ts +++ b/services/workflows-service/src/workflow/workflow.module.ts @@ -20,10 +20,8 @@ import { FilterService } from '@/filter/filter.service'; import { PrismaModule } from '@/prisma/prisma.module'; import { ProjectScopeService } from '@/project/project-scope.service'; import { ProjectModule } from '@/project/project.module'; -import { FileService } from '@/providers/file/file.service'; import { SalesforceIntegrationRepository } from '@/salesforce/salesforce-integration.repository'; import { SalesforceService } from '@/salesforce/salesforce.service'; -import { FileRepository } from '@/storage/storage.repository'; import { StorageService } from '@/storage/storage.service'; import { UiDefinitionRepository } from '@/ui-definition/ui-definition.repository'; import { UiDefinitionService } from '@/ui-definition/ui-definition.service'; @@ -46,6 +44,8 @@ import { BusinessReportService } from '@/business-report/business-report.service import { RuleEngineModule } from '@/rule-engine/rule-engine.module'; import { SentryService } from '@/sentry/sentry.service'; import { SecretsManagerModule } from '@/secrets-manager/secrets-manager.module'; +import { FileModule } from '@/providers/file/file.module'; +import { FileRepository } from '@/storage/storage.repository'; @Module({ controllers: [WorkflowControllerExternal, WorkflowControllerInternal], @@ -57,6 +57,7 @@ import { SecretsManagerModule } from '@/secrets-manager/secrets-manager.module'; PrismaModule, CustomerModule, forwardRef(() => BusinessReportModule), + forwardRef(() => FileModule), WorkflowDefinitionModule, AlertModule, BusinessModule, @@ -78,7 +79,6 @@ import { SecretsManagerModule } from '@/secrets-manager/secrets-manager.module'; FileRepository, WorkflowService, HookCallbackHandlerService, - FileService, WorkflowEventEmitterService, DocumentChangedWebhookCaller, WorkflowCompletedWebhookCaller, @@ -102,7 +102,6 @@ import { SecretsManagerModule } from '@/secrets-manager/secrets-manager.module'; ACLModule, AuthModule, StorageService, - FileRepository, EndUserService, EndUserRepository, WorkflowDefinitionService, diff --git a/services/workflows-service/src/workflow/workflow.service.ts b/services/workflows-service/src/workflow/workflow.service.ts index ec72256b6d..9039655811 100644 --- a/services/workflows-service/src/workflow/workflow.service.ts +++ b/services/workflows-service/src/workflow/workflow.service.ts @@ -22,6 +22,7 @@ import { defaultPrismaTransactionOptions, } from '@/prisma/prisma.util'; import { ProjectScopeService } from '@/project/project-scope.service'; +// eslint-disable-next-line import/no-cycle import { FileService } from '@/providers/file/file.service'; import { RiskRuleService, TFindAllRulesOptions } from '@/rule-engine/risk-rule.service'; import { RuleEngineService } from '@/rule-engine/rule-engine.service';