From d35a4ea8a72fdda1ca6b2983db75992319fcba3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Tue, 30 Apr 2024 13:05:26 +0200 Subject: [PATCH] feat: locking mechanism (#4065) --- .github/workflows/pullRequests.yml | 2 +- .github/workflows/pushDev.yml | 2 +- .github/workflows/pushNext.yml | 2 +- apps/api/graphql/package.json | 1 + apps/api/graphql/src/index.ts | 2 + apps/api/graphql/tsconfig.json | 5 + packages/api-file-manager/package.json | 2 +- .../contentAPI/extendingGqlSchema.test.ts | 4 +- .../extendingGqlSchemaError.test.ts | 4 +- .../src/crud/AccessControl/AccessControl.ts | 12 +- .../src/crud/contentEntry.crud.ts | 5 - .../src/crud/contentModel.crud.ts | 16 +- .../contentModelManagerFactory.ts | 8 +- .../crud/contentModel/validateModelFields.ts | 12 +- .../src/export/graphql/index.ts | 4 +- .../src/graphql/buildSchemaPlugins.ts | 4 +- .../src/graphql/createExecutableSchema.ts | 4 +- .../src/graphql/generateSchema.ts | 6 +- .../src/graphql/schema/baseContentSchema.ts | 25 +- .../src/graphql/schema/baseSchema.ts | 16 +- .../src/graphql/schema/contentEntries.ts | 10 +- .../src/graphql/schema/contentModelGroups.ts | 6 +- .../src/graphql/schema/contentModels.ts | 6 +- .../src/graphql/schema/schemaPlugins.ts | 12 +- packages/api-headless-cms/src/index.ts | 1 + .../src/modelManager/index.ts | 6 +- .../src/plugins/CmsGraphQLSchemaPlugin.ts | 13 - .../CmsGraphQLSchemaPlugin.ts | 24 + .../plugins/CmsGraphQLSchemaPlugin/index.ts | 1 + packages/api-headless-cms/src/types/types.ts | 59 ++- .../src/utils/getSchemaFromFieldPlugins.ts | 12 +- packages/api-locking-mechanism/.babelrc.js | 1 + packages/api-locking-mechanism/LICENSE | 21 + packages/api-locking-mechanism/README.md | 10 + .../__tests__/graphql/getLockRecord.test.ts | 27 ++ .../graphql/getLockedEntryLockRecord.test.ts | 88 ++++ .../__tests__/graphql/isEntryLocked.test.ts | 23 + .../__tests__/graphql/listLockRecords.test.ts | 100 ++++ .../__tests__/graphql/lockEntry.test.ts | 141 ++++++ .../graphql/requestEntryUnlock.test.ts | 106 +++++ .../__tests__/graphql/unlockEntry.test.ts | 150 ++++++ .../__tests__/graphql/updateEntryLock.test.ts | 109 +++++ .../helpers/graphql/lockingMechanism.ts | 250 ++++++++++ .../__tests__/helpers/identity.ts | 18 + .../__tests__/helpers/locales.ts | 27 ++ .../__tests__/helpers/permissions.ts | 47 ++ .../__tests__/helpers/plugins.ts | 116 +++++ .../__tests__/helpers/tenancySecurity.ts | 69 +++ .../__tests__/helpers/useGraphQLHandler.ts | 139 ++++++ .../useCase/isEntryLockedUseCase.test.ts | 75 +++ .../useCase/kickOutCurrentUser.test.ts | 52 +++ .../useCase/lockEntryUseCase.test.ts | 66 +++ .../useCase/unlockEntryRequestUseCase.test.ts | 101 ++++ .../useCase/unlockEntryUseCase.test.ts | 40 ++ .../utils/convertWhereCondition.test.ts | 173 +++++++ .../utils/validateSameIdentity.test.ts | 48 ++ packages/api-locking-mechanism/jest.setup.js | 11 + packages/api-locking-mechanism/package.json | 64 +++ .../src/abstractions/IGetLockRecordUseCase.ts | 11 + .../IGetLockedEntryLockRecordUseCase.ts | 13 + .../src/abstractions/IIsEntryLocked.ts | 11 + .../IKickOutCurrentUserUseCase.ts | 7 + .../IListAllLockRecordsUseCase.ts | 18 + .../abstractions/IListLockRecordsUseCase.ts | 14 + .../src/abstractions/ILockEntryUseCase.ts | 14 + .../IUnlockEntryRequestUseCase.ts | 14 + .../src/abstractions/IUnlockEntryUseCase.ts | 15 + .../abstractions/IUpdateEntryLockUseCase.ts | 14 + .../api-locking-mechanism/src/crud/crud.ts | 249 ++++++++++ .../api-locking-mechanism/src/crud/model.ts | 150 ++++++ .../src/graphql/schema.ts | 292 ++++++++++++ packages/api-locking-mechanism/src/index.ts | 34 ++ packages/api-locking-mechanism/src/types.ts | 239 ++++++++++ .../GetLockRecord/GetLockRecordUseCase.ts | 41 ++ .../GetLockedEntryLockRecordUseCase.ts | 39 ++ .../IsEntryLocked/IsEntryLockedUseCase.ts | 46 ++ .../KickOutCurrentUserUseCase.ts | 53 +++ .../ListAllLockRecordsUseCase.ts | 38 ++ .../ListLockRecordsUseCase.ts | 37 ++ .../LockEntryUseCase/LockEntryUseCase.ts | 68 +++ .../UnlockEntryUseCase/UnlockEntryUseCase.ts | 94 ++++ .../UnlockEntryRequestUseCase.ts | 103 ++++ .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 69 +++ .../src/useCases/index.ts | 120 +++++ .../src/utils/calculateExpiresOn.ts | 10 + .../src/utils/checkPermissions.ts | 14 + .../src/utils/convertEntryToLockRecord.ts | 129 ++++++ .../src/utils/convertWhereCondition.ts | 37 ++ .../src/utils/getTimeout.ts | 13 + .../src/utils/isLockedFactory.ts | 17 + .../src/utils/lockRecordDatabaseId.ts | 15 + .../src/utils/resolve.ts | 27 ++ .../src/utils/validateSameIdentity.ts | 19 + .../api-locking-mechanism/tsconfig.build.json | 27 ++ packages/api-locking-mechanism/tsconfig.json | 58 +++ .../api-locking-mechanism/webiny.config.js | 8 + .../api-prerendering-service/package.json | 2 +- packages/api-wcp/src/graphql.ts | 1 + packages/api-wcp/src/types.ts | 1 + .../src/context/WebsocketsContext.ts | 6 +- .../abstractions/IWebsocketsContext.ts | 4 +- packages/api-websockets/src/types.ts | 6 +- packages/app-aco/package.json | 2 +- packages/app-admin-rmwc/package.json | 2 +- .../Table/Cells/CellActions.tsx | 6 +- .../useSaveAndPublish.tsx | 10 +- .../Header/SaveContent/useSave.tsx | 5 +- .../src/admin/hooks/usePermission.ts | 2 +- .../views/contentEntries/ContentEntry.tsx | 40 +- .../hooks/useContentEntriesList.tsx | 30 +- packages/app-locking-mechanism/.babelrc.js | 1 + packages/app-locking-mechanism/LICENSE | 21 + packages/app-locking-mechanism/README.md | 12 + packages/app-locking-mechanism/package.json | 58 +++ .../components/HeadlessCmsActionsAcoCell.tsx | 56 +++ .../ContentEntryGuard.tsx | 72 +++ .../ContentEntryLocker.tsx | 112 +++++ .../HeadlessCmsContentEntry.tsx | 27 ++ .../HeadlessCmsContentEntry/index.ts | 1 + .../components/LockedRecord/LockedRecord.tsx | 142 ++++++ .../LockedRecord/LockedRecordForceUnlock.tsx | 99 ++++ .../src/components/LockedRecord/index.ts | 1 + .../components/LockingMechanismProvider.tsx | 139 ++++++ .../src/components/assets/lock.svg | 13 + .../UseContentEntriesListHookDecorator.ts | 25 + .../UseSaveAndPublishHookDecorator.tsx | 52 +++ .../decorators/UseSaveHookDecorator.tsx | 47 ++ .../src/domain/LockingMechanism.ts | 438 ++++++++++++++++++ .../src/domain/LockingMechanismClient.ts | 29 ++ .../domain/LockingMechanismGetLockRecord.ts | 41 ++ ...ockingMechanismGetLockedEntryLockRecord.ts | 41 ++ .../domain/LockingMechanismIsEntryLocked.ts | 39 ++ .../domain/LockingMechanismListLockRecords.ts | 48 ++ .../src/domain/LockingMechanismLockEntry.ts | 26 ++ .../src/domain/LockingMechanismUnlockEntry.ts | 39 ++ .../LockingMechanismUnlockEntryRequest.ts | 26 ++ .../domain/LockingMechanismUpdateEntryLock.ts | 40 ++ .../domain/abstractions/ILockingMechanism.ts | 62 +++ .../abstractions/ILockingMechanismClient.ts | 7 + .../ILockingMechanismGetLockRecord.ts | 17 + ...ockingMechanismGetLockedEntryLockRecord.ts | 17 + .../ILockingMechanismIsEntryLocked.ts | 12 + .../ILockingMechanismListLockRecords.ts | 29 ++ .../ILockingMechanismLockEntry.ts | 15 + .../ILockingMechanismUnlockEntry.ts | 18 + .../ILockingMechanismUnlockEntryRequest.ts | 17 + .../ILockingMechanismUpdateEntryLock.ts | 16 + .../src/domain/graphql/fields.ts | 29 ++ .../src/domain/graphql/getLockRecord.ts | 30 ++ .../graphql/getLockedEntryLockRecord.ts | 31 ++ .../src/domain/graphql/isEntryLocked.ts | 28 ++ .../src/domain/graphql/listLockRecords.ts | 48 ++ .../src/domain/graphql/lockEntry.ts | 32 ++ .../src/domain/graphql/unlockEntry.ts | 30 ++ .../src/domain/graphql/unlockEntryRequest.ts | 33 ++ .../src/domain/graphql/updateEntryLock.ts | 31 ++ .../utils/createLockingMechanismClient.ts | 12 + .../utils/createLockingMechanismError.ts | 23 + .../app-locking-mechanism/src/hooks/index.ts | 2 + .../src/hooks/useLockingMechanism.ts | 14 + .../src/hooks/usePermission.ts | 14 + packages/app-locking-mechanism/src/index.tsx | 41 ++ packages/app-locking-mechanism/src/types.ts | 100 ++++ .../src/utils/createCacheKey.ts | 19 + .../app-locking-mechanism/tsconfig.build.json | 23 + packages/app-locking-mechanism/tsconfig.json | 46 ++ .../app-locking-mechanism/webiny.config.js | 8 + .../package.json | 2 +- packages/app-serverless-cms/package.json | 1 + packages/app-serverless-cms/src/Admin.tsx | 4 +- .../app-serverless-cms/tsconfig.build.json | 1 + packages/app-serverless-cms/tsconfig.json | 3 + packages/app-trash-bin/package.json | 2 +- packages/app-wcp/src/WcpProvider.tsx | 3 + ...ider.tsx => WebsocketsContextProvider.tsx} | 47 +- .../src/domain/WebsocketsConnection.ts | 4 +- .../IWebsocketsSubscriptionManager.ts | 2 +- .../src/hooks/useWebsockets.tsx | 2 +- packages/app-websockets/src/index.tsx | 10 +- packages/app-websockets/src/types.ts | 11 +- .../src/client-dynamodb/getDocumentClient.ts | 21 +- packages/error/src/index.ts | 1 + .../src/plugins/GraphQLSchemaPlugin.ts | 17 +- yarn.lock | 107 ++++- 184 files changed, 7095 insertions(+), 244 deletions(-) delete mode 100644 packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts create mode 100644 packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts create mode 100644 packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts create mode 100644 packages/api-locking-mechanism/.babelrc.js create mode 100644 packages/api-locking-mechanism/LICENSE create mode 100644 packages/api-locking-mechanism/README.md create mode 100644 packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/getLockedEntryLockRecord.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/updateEntryLock.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/identity.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/locales.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/permissions.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/plugins.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/kickOutCurrentUser.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/utils/convertWhereCondition.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/utils/validateSameIdentity.test.ts create mode 100644 packages/api-locking-mechanism/jest.setup.js create mode 100644 packages/api-locking-mechanism/package.json create mode 100644 packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IGetLockedEntryLockRecordUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IIsEntryLocked.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IKickOutCurrentUserUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IListAllLockRecordsUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IUpdateEntryLockUseCase.ts create mode 100644 packages/api-locking-mechanism/src/crud/crud.ts create mode 100644 packages/api-locking-mechanism/src/crud/model.ts create mode 100644 packages/api-locking-mechanism/src/graphql/schema.ts create mode 100644 packages/api-locking-mechanism/src/index.ts create mode 100644 packages/api-locking-mechanism/src/types.ts create mode 100644 packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/index.ts create mode 100644 packages/api-locking-mechanism/src/utils/calculateExpiresOn.ts create mode 100644 packages/api-locking-mechanism/src/utils/checkPermissions.ts create mode 100644 packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts create mode 100644 packages/api-locking-mechanism/src/utils/convertWhereCondition.ts create mode 100644 packages/api-locking-mechanism/src/utils/getTimeout.ts create mode 100644 packages/api-locking-mechanism/src/utils/isLockedFactory.ts create mode 100644 packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts create mode 100644 packages/api-locking-mechanism/src/utils/resolve.ts create mode 100644 packages/api-locking-mechanism/src/utils/validateSameIdentity.ts create mode 100644 packages/api-locking-mechanism/tsconfig.build.json create mode 100644 packages/api-locking-mechanism/tsconfig.json create mode 100644 packages/api-locking-mechanism/webiny.config.js create mode 100644 packages/app-locking-mechanism/.babelrc.js create mode 100644 packages/app-locking-mechanism/LICENSE create mode 100644 packages/app-locking-mechanism/README.md create mode 100644 packages/app-locking-mechanism/package.json create mode 100644 packages/app-locking-mechanism/src/components/HeadlessCmsActionsAcoCell.tsx create mode 100644 packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryGuard.tsx create mode 100644 packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx create mode 100644 packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/HeadlessCmsContentEntry.tsx create mode 100644 packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/index.ts create mode 100644 packages/app-locking-mechanism/src/components/LockedRecord/LockedRecord.tsx create mode 100644 packages/app-locking-mechanism/src/components/LockedRecord/LockedRecordForceUnlock.tsx create mode 100644 packages/app-locking-mechanism/src/components/LockedRecord/index.ts create mode 100644 packages/app-locking-mechanism/src/components/LockingMechanismProvider.tsx create mode 100644 packages/app-locking-mechanism/src/components/assets/lock.svg create mode 100644 packages/app-locking-mechanism/src/components/decorators/UseContentEntriesListHookDecorator.ts create mode 100644 packages/app-locking-mechanism/src/components/decorators/UseSaveAndPublishHookDecorator.tsx create mode 100644 packages/app-locking-mechanism/src/components/decorators/UseSaveHookDecorator.tsx create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanism.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismClient.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismGetLockRecord.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismGetLockedEntryLockRecord.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismIsEntryLocked.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismListLockRecords.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismLockEntry.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntry.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntryRequest.ts create mode 100644 packages/app-locking-mechanism/src/domain/LockingMechanismUpdateEntryLock.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanism.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismClient.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockRecord.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockedEntryLockRecord.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismIsEntryLocked.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismListLockRecords.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismLockEntry.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntry.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntryRequest.ts create mode 100644 packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUpdateEntryLock.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/fields.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/getLockRecord.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/getLockedEntryLockRecord.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/isEntryLocked.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/listLockRecords.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/lockEntry.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/unlockEntry.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/unlockEntryRequest.ts create mode 100644 packages/app-locking-mechanism/src/domain/graphql/updateEntryLock.ts create mode 100644 packages/app-locking-mechanism/src/domain/utils/createLockingMechanismClient.ts create mode 100644 packages/app-locking-mechanism/src/domain/utils/createLockingMechanismError.ts create mode 100644 packages/app-locking-mechanism/src/hooks/index.ts create mode 100644 packages/app-locking-mechanism/src/hooks/useLockingMechanism.ts create mode 100644 packages/app-locking-mechanism/src/hooks/usePermission.ts create mode 100644 packages/app-locking-mechanism/src/index.tsx create mode 100644 packages/app-locking-mechanism/src/types.ts create mode 100644 packages/app-locking-mechanism/src/utils/createCacheKey.ts create mode 100644 packages/app-locking-mechanism/tsconfig.build.json create mode 100644 packages/app-locking-mechanism/tsconfig.json create mode 100644 packages/app-locking-mechanism/webiny.config.js rename packages/app-websockets/src/{WebsocketsProvider.tsx => WebsocketsContextProvider.tsx} (78%) diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index 3c10d284ba8..d9fe4e5f955 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -177,7 +177,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-locking-mechanism","id":"api-locking-mechanism"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/pushDev.yml b/.github/workflows/pushDev.yml index 81435589b99..97093fc72df 100644 --- a/.github/workflows/pushDev.yml +++ b/.github/workflows/pushDev.yml @@ -143,7 +143,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-locking-mechanism","id":"api-locking-mechanism"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/pushNext.yml b/.github/workflows/pushNext.yml index 15d2b82adf6..bc81e311df8 100644 --- a/.github/workflows/pushNext.yml +++ b/.github/workflows/pushNext.yml @@ -143,7 +143,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-locking-mechanism","id":"api-locking-mechanism"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/apps/api/graphql/package.json b/apps/api/graphql/package.json index 669b51edcdb..fe291afec65 100644 --- a/apps/api/graphql/package.json +++ b/apps/api/graphql/package.json @@ -25,6 +25,7 @@ "@webiny/api-i18n": "0.0.0", "@webiny/api-i18n-content": "0.0.0", "@webiny/api-i18n-ddb": "0.0.0", + "@webiny/api-locking-mechanism": "0.0.0", "@webiny/api-page-builder": "0.0.0", "@webiny/api-page-builder-aco": "0.0.0", "@webiny/api-page-builder-import-export": "0.0.0", diff --git a/apps/api/graphql/src/index.ts b/apps/api/graphql/src/index.ts index 7328c957be8..6cfe84d46f3 100644 --- a/apps/api/graphql/src/index.ts +++ b/apps/api/graphql/src/index.ts @@ -43,6 +43,7 @@ import { createBenchmarkEnablePlugin } from "~/plugins/benchmarkEnable"; import { createCountDynamoDbTask } from "~/plugins/countDynamoDbTask"; import { createContinuingTask } from "~/plugins/continuingTask"; import { createWebsockets } from "@webiny/api-websockets"; +import { createLockingMechanism } from "@webiny/api-locking-mechanism"; const debug = process.env.DEBUG === "true"; const documentClient = getDocumentClient(); @@ -70,6 +71,7 @@ export const handler = createHandler({ }) }), createHeadlessCmsGraphQL(), + createLockingMechanism(), createBackgroundTasks(), createFileManagerContext({ storageOperations: createFileManagerStorageOperations({ diff --git a/apps/api/graphql/tsconfig.json b/apps/api/graphql/tsconfig.json index 4bc6ae545c2..d7234fbb2da 100644 --- a/apps/api/graphql/tsconfig.json +++ b/apps/api/graphql/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../packages/api-audit-logs/tsconfig.build.json" }, + { + "path": "../../../packages/api-locking-mechanism/tsconfig.build.json" + }, { "path": "../../../packages/api-file-manager/tsconfig.build.json" }, @@ -153,6 +156,8 @@ "@webiny/api-headless-cms-aco": ["../../../packages/api-headless-cms-aco/src"], "@webiny/api-headless-cms-ddb/*": ["../../../packages/api-headless-cms-ddb/src/*"], "@webiny/api-headless-cms-ddb": ["../../../packages/api-headless-cms-ddb/src"], + "@webiny/api-locking-mechanism/*": ["../../../packages/api-locking-mechanism/src/*"], + "@webiny/api-locking-mechanism": ["../../../packages/api-locking-mechanism/src"], "@webiny/api-headless-cms-tasks/*": ["../../../packages/api-headless-cms-tasks/src/*"], "@webiny/api-headless-cms-tasks": ["../../../packages/api-headless-cms-tasks/src"], "@webiny/api-i18n/*": ["../../../packages/api-i18n/src/*"], diff --git a/packages/api-file-manager/package.json b/packages/api-file-manager/package.json index 3b350eb9c11..693778bbe0a 100644 --- a/packages/api-file-manager/package.json +++ b/packages/api-file-manager/package.json @@ -35,7 +35,7 @@ "@webiny/tasks": "0.0.0", "@webiny/validation": "0.0.0", "lodash": "4.17.21", - "object-hash": "^2.1.1" + "object-hash": "^3.0.0" }, "devDependencies": { "@babel/cli": "^7.23.9", diff --git a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts index 2ec68bcf883..12ae6de6f8f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts @@ -1,7 +1,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; -const graphqlSchemaPlugin = new CmsGraphQLSchemaPlugin({ +const graphqlSchemaPlugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` extend type Query { getOne: Int diff --git a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts index 8c2f1d2690b..5830443244a 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts @@ -1,7 +1,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; -const graphqlSchemaPlugin = new CmsGraphQLSchemaPlugin({ +const graphqlSchemaPlugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type BrokenType { # types without fields are invalid diff --git a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts index 29cbaa83c5d..fcc9249455f 100644 --- a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts +++ b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts @@ -34,11 +34,11 @@ interface CanAccessModelParams extends GetModelsAccessControlListParams { } interface GetEntriesAccessControlListParams { - model: CmsModel; - entry?: CmsEntry; + model: Pick; + entry?: Pick; } -interface CanAccessEntryParams extends GetEntriesAccessControlListParams { +export interface CanAccessEntryParams extends GetEntriesAccessControlListParams { rwd?: string; pw?: string; } @@ -57,6 +57,10 @@ interface EntriesAccessControlEntry extends AccessControlEntry { type EntriesAccessControlList = EntriesAccessControlEntry[]; +interface IModelAuthorizationDisabledParams { + model: Pick; +} + export class AccessControl { getIdentity: AccessControlParams["getIdentity"]; getGroupsPermissions: AccessControlParams["getGroupsPermissions"]; @@ -653,7 +657,7 @@ export class AccessControl { return permissions.some(p => this.fullAccessPermissions.filter(Boolean).includes(p.name)); } - private modelAuthorizationDisabled(params: { model: CmsModel }) { + private modelAuthorizationDisabled(params: IModelAuthorizationDisabledParams) { if ("authorization" in params.model) { const { authorization } = params.model; if (typeof authorization === "boolean") { diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 37e366b0dba..408a960124b 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -12,7 +12,6 @@ import { CmsModel, CmsStorageEntry, EntryBeforeListTopicParams, - HeadlessCms, HeadlessCmsStorageOperations, OnEntryAfterCreateTopicParams, OnEntryAfterDeleteMultipleTopicParams, @@ -1303,8 +1302,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm ); }, /** - * TODO determine if this method is required at all. - * * @internal */ async getEntry(model, params) { @@ -1326,7 +1323,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); }, async listLatestEntries( - this: HeadlessCms, model: CmsModel, params?: CmsEntryListParams ): Promise<[CmsEntry[], CmsEntryMeta]> { @@ -1338,7 +1334,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm ); }, async listDeletedEntries( - this: HeadlessCms, model: CmsModel, params?: CmsEntryListParams ): Promise<[CmsEntry[], CmsEntryMeta]> { diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index f01e3397972..eb6ac857168 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -70,11 +70,11 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex }; const managers = new Map(); - const updateManager = async ( + const updateManager = async ( context: CmsContext, model: CmsModel - ): Promise => { - const manager = await contentModelManagerFactory(context, model); + ): Promise> => { + const manager = await contentModelManagerFactory(context, model); managers.set(model.modelId, manager); return manager; }; @@ -206,15 +206,15 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex }); }; - const getEntryManager: CmsModelContext["getEntryManager"] = async ( - target - ): Promise => { + const getEntryManager: CmsModelContext["getEntryManager"] = async ( + target: string | Pick + ): Promise> => { const modelId = typeof target === "string" ? target : target.modelId; if (managers.has(modelId)) { - return managers.get(modelId) as CmsModelManager; + return managers.get(modelId) as CmsModelManager; } const model = await getModelFromCache(modelId); - return await updateManager(context, model); + return await updateManager(context, model); }; /** diff --git a/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts b/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts index b9afcb8e1c2..52628bd0b25 100644 --- a/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts +++ b/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts @@ -2,22 +2,22 @@ import { CmsModel, CmsContext, ModelManagerPlugin, CmsModelManager } from "~/typ const defaultName = "content-model-manager-default"; -export const contentModelManagerFactory = async ( +export const contentModelManagerFactory = async ( context: CmsContext, model: CmsModel -): Promise => { +): Promise> => { const pluginsByType = context.plugins .byType("cms-content-model-manager") .reverse(); for (const plugin of pluginsByType) { const target = Array.isArray(plugin.modelId) ? plugin.modelId : [plugin.modelId]; if (target.includes(model.modelId) === true && plugin.name !== defaultName) { - return await plugin.create(context, model); + return await plugin.create(context, model); } } const plugin = pluginsByType.find(plugin => plugin.name === defaultName); if (!plugin) { throw new Error("There is no default plugin to create CmsModelManager"); } - return await plugin.create(context, model); + return await plugin.create(context, model); }; diff --git a/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts b/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts index 68fb9d6fecd..1ec1e69f229 100644 --- a/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts +++ b/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts @@ -1,8 +1,8 @@ import gql from "graphql-tag"; import WebinyError from "@webiny/error"; import { - CmsModel, CmsContext, + CmsModel, CmsModelField, CmsModelFieldToGraphQLPlugin, CmsModelFieldToGraphQLPluginValidateChildFieldsValidate, @@ -16,7 +16,11 @@ import { getBaseFieldType } from "~/utils/getBaseFieldType"; import { getContentModelTitleFieldId } from "./fields/titleField"; import { getContentModelDescriptionFieldId } from "./fields/descriptionField"; import { getContentModelImageFieldId } from "./fields/imageField"; -import { CmsGraphQLSchemaPlugin, CmsGraphQLSchemaSorterPlugin } from "~/plugins"; +import { + CmsGraphQLSchemaPlugin, + CmsGraphQLSchemaSorterPlugin, + ICmsGraphQLSchemaPlugin +} from "~/plugins"; import { buildSchemaPlugins } from "~/graphql/buildSchemaPlugins"; import { createExecutableSchema } from "~/graphql/createExecutableSchema"; import { generateAlphaNumericId } from "@webiny/utils"; @@ -227,8 +231,8 @@ const createGraphQLSchema = async (params: CreateGraphQLSchemaParams): Promise(CmsGraphQLSchemaPlugin.type) - .reduce>((collection, plugin) => { + .byType(CmsGraphQLSchemaPlugin.type) + .reduce>((collection, plugin) => { const name = plugin.name || `${CmsGraphQLSchemaPlugin.type}-${generateAlphaNumericId(16)}`; collection[name] = plugin; diff --git a/packages/api-headless-cms/src/export/graphql/index.ts b/packages/api-headless-cms/src/export/graphql/index.ts index 1f3a8d1e1a8..0521b0f9734 100644 --- a/packages/api-headless-cms/src/export/graphql/index.ts +++ b/packages/api-headless-cms/src/export/graphql/index.ts @@ -1,9 +1,9 @@ -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; import { ErrorResponse, Response } from "@webiny/handler-graphql"; import { ContextPlugin } from "@webiny/handler"; import { CmsContext } from "~/types"; -const plugin = new CmsGraphQLSchemaPlugin({ +const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type CmsExportStructureResponse { data: String diff --git a/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts b/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts index 6973601daea..5011aebee0e 100644 --- a/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts +++ b/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts @@ -4,7 +4,7 @@ import { createContentEntriesSchema } from "./schema/contentEntries"; import { createGroupsSchema } from "./schema/contentModelGroups"; import { createBaseContentSchema } from "./schema/baseContentSchema"; import { generateSchemaPlugins } from "./schema/schemaPlugins"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { ICmsGraphQLSchemaPlugin } from "~/plugins"; /** * This factory is called whenever we need to generate graphql-schema plugins using the current context. @@ -15,7 +15,7 @@ interface BuildSchemaPluginsParams { } export const buildSchemaPlugins = async ( params: BuildSchemaPluginsParams -): Promise => { +): Promise => { return [ // Base GQL types and scalars createBaseContentSchema(params), diff --git a/packages/api-headless-cms/src/graphql/createExecutableSchema.ts b/packages/api-headless-cms/src/graphql/createExecutableSchema.ts index d244cc55c09..553f18ed97a 100644 --- a/packages/api-headless-cms/src/graphql/createExecutableSchema.ts +++ b/packages/api-headless-cms/src/graphql/createExecutableSchema.ts @@ -1,8 +1,8 @@ import { makeExecutableSchema } from "@graphql-tools/schema"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { ICmsGraphQLSchemaPlugin } from "~/plugins"; interface Params { - plugins: CmsGraphQLSchemaPlugin[]; + plugins: ICmsGraphQLSchemaPlugin[]; } export const createExecutableSchema = (params: Params) => { diff --git a/packages/api-headless-cms/src/graphql/generateSchema.ts b/packages/api-headless-cms/src/graphql/generateSchema.ts index bb78dc117fc..8420a722b79 100644 --- a/packages/api-headless-cms/src/graphql/generateSchema.ts +++ b/packages/api-headless-cms/src/graphql/generateSchema.ts @@ -2,7 +2,7 @@ import { CmsContext, CmsModel } from "~/types"; import { buildSchemaPlugins } from "./buildSchemaPlugins"; import { createExecutableSchema } from "./createExecutableSchema"; import { GraphQLSchema } from "graphql/type"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { CmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; interface GenerateSchemaParams { context: CmsContext; @@ -11,7 +11,7 @@ interface GenerateSchemaParams { export const generateSchema = async (params: GenerateSchemaParams): Promise => { const { context, models } = params; - let generatedSchemaPlugins: CmsGraphQLSchemaPlugin[] = []; + let generatedSchemaPlugins: ICmsGraphQLSchemaPlugin[] = []; try { generatedSchemaPlugins = await buildSchemaPlugins({ context, models }); } catch (ex) { @@ -21,7 +21,7 @@ export const generateSchema = async (params: GenerateSchemaParams): Promise( + const schemaPlugins = context.plugins.byType( CmsGraphQLSchemaPlugin.type ); return createExecutableSchema({ diff --git a/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts b/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts index a8693fa4100..5a8efd77fab 100644 --- a/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts +++ b/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts @@ -1,28 +1,29 @@ import { GraphQLScalarPlugin } from "@webiny/handler-graphql/types"; import { CmsContext } from "~/types"; import { - RefInputScalar, - NumberScalar, AnyScalar, - DateTimeScalar, DateScalar, - TimeScalar, - LongScalar, + DateTimeScalar, + DateTimeZScalar, JsonScalar, - DateTimeZScalar + LongScalar, + NumberScalar, + RefInputScalar, + TimeScalar } from "@webiny/handler-graphql/builtInTypes"; import { GraphQLScalarType } from "graphql"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; interface Params { context: CmsContext; } -export const createBaseContentSchema = ({ context }: Params): CmsGraphQLSchemaPlugin => { + +export const createBaseContentSchema = ({ context }: Params): ICmsGraphQLSchemaPlugin => { const scalars = context.plugins .byType("graphql-scalar") .map(item => item.scalar); - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` ${scalars.map(scalar => `scalar ${scalar.name}`).join(" ")} scalar JSON @@ -41,12 +42,6 @@ export const createBaseContentSchema = ({ context }: Params): CmsGraphQLSchemaPl _empty: String } - type CmsIdentity { - id: String - displayName: String - type: String - } - enum CmsEntryStatusType { latest published diff --git a/packages/api-headless-cms/src/graphql/schema/baseSchema.ts b/packages/api-headless-cms/src/graphql/schema/baseSchema.ts index 0b1e815a9a6..9e6d8dfc9fd 100644 --- a/packages/api-headless-cms/src/graphql/schema/baseSchema.ts +++ b/packages/api-headless-cms/src/graphql/schema/baseSchema.ts @@ -1,6 +1,6 @@ import { CmsContext, CmsModelFieldValidatorPlugin } from "~/types"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; +import { GraphQLSchemaPlugin, IGraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { PluginsContainer } from "@webiny/plugins"; import { ContextPlugin } from "@webiny/api"; import camelCase from "lodash/camelCase"; @@ -22,16 +22,22 @@ const createSkipValidatorEnum = (plugins: PluginsContainer) => { } return /* GraphQL */ ` enum SkipValidatorEnum { - ${validators.join("\n")} + ${validators.join("\n")} } `; }; -const createSchema = (plugins: PluginsContainer): GraphQLSchemaPlugin[] => { +const createSchema = (plugins: PluginsContainer): IGraphQLSchemaPlugin[] => { const skipValidatorEnum = createSkipValidatorEnum(plugins); - const cmsPlugin = new CmsGraphQLSchemaPlugin({ + const cmsPlugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` + type CmsIdentity { + id: String + displayName: String + type: String + } + type CmsError { code: String message: String diff --git a/packages/api-headless-cms/src/graphql/schema/contentEntries.ts b/packages/api-headless-cms/src/graphql/schema/contentEntries.ts index 4e82f68c218..ba610f7fe74 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentEntries.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentEntries.ts @@ -3,12 +3,12 @@ import { ErrorResponse, Response } from "@webiny/handler-graphql"; import { CmsContext, CmsEntry, CmsEntryListWhere, CmsIdentity, CmsModel } from "~/types"; import { NotAuthorizedResponse } from "@webiny/api-security"; import { getEntryTitle } from "~/utils/getEntryTitle"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; import { getEntryDescription } from "~/utils/getEntryDescription"; import { getEntryImage } from "~/utils/getEntryImage"; import { entryFieldFromStorageTransform } from "~/utils/entryStorage"; import { Resolvers } from "@webiny/handler-graphql/types"; -import { ENTRY_META_FIELDS, isNullableEntryMetaField, isDateTimeEntryMetaField } from "~/constants"; +import { ENTRY_META_FIELDS, isDateTimeEntryMetaField, isNullableEntryMetaField } from "~/constants"; interface EntriesByModel { [key: string]: string[]; @@ -310,9 +310,9 @@ interface Params { export const createContentEntriesSchema = ({ context -}: Params): CmsGraphQLSchemaPlugin => { +}: Params): ICmsGraphQLSchemaPlugin => { if (!context.cms.MANAGE) { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: "", resolvers: {} }); @@ -327,7 +327,7 @@ export const createContentEntriesSchema = ({ return `${field}: ${fieldType}${isNullable}`; }).join("\n"); - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ // Had to remove /* GraphQL */ because prettier would not format the code correctly. typeDefs: ` type CmsModelMeta { diff --git a/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts b/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts index 5c000251ca0..26f104a7521 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts @@ -2,12 +2,12 @@ import { ErrorResponse, NotFoundError, Response } from "@webiny/handler-graphql" import { CmsContext } from "~/types"; import { Resolvers } from "@webiny/handler-graphql/types"; import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; interface Params { context: CmsContext; } -export const createGroupsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin => { +export const createGroupsSchema = ({ context }: Params): ICmsGraphQLSchemaPlugin => { let manageSchema = ""; if (context.cms.MANAGE) { manageSchema = /* GraphQL */ ` @@ -134,7 +134,7 @@ export const createGroupsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin }; } - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type CmsContentModelGroup { id: ID! diff --git a/packages/api-headless-cms/src/graphql/schema/contentModels.ts b/packages/api-headless-cms/src/graphql/schema/contentModels.ts index 7098208be42..94f6c1c12f9 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentModels.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentModels.ts @@ -2,14 +2,14 @@ import { ErrorResponse, NotFoundError, Response } from "@webiny/handler-graphql" import { CmsContext, CmsModel } from "~/types"; import { Resolvers } from "@webiny/handler-graphql/types"; import { CmsModelPlugin } from "~/plugins/CmsModelPlugin"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; import { toSlug } from "~/utils/toSlug"; interface Params { context: CmsContext; } -export const createModelsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin => { +export const createModelsSchema = ({ context }: Params): ICmsGraphQLSchemaPlugin => { const resolvers: Resolvers = { Query: { getContentModel: async (_: unknown, args: any, context) => { @@ -225,7 +225,7 @@ export const createModelsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin `; } - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type CmsFieldValidation { name: String! diff --git a/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts b/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts index c42f5fa4994..8b1d2867f0f 100644 --- a/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts +++ b/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts @@ -5,7 +5,11 @@ import { createManageResolvers } from "./createManageResolvers"; import { createReadResolvers } from "./createReadResolvers"; import { createPreviewResolvers } from "./createPreviewResolvers"; import { createGraphQLSchemaPluginFromFieldPlugins } from "~/utils/getSchemaFromFieldPlugins"; -import { CmsGraphQLSchemaPlugin, CmsGraphQLSchemaSorterPlugin } from "~/plugins"; +import { + CmsGraphQLSchemaSorterPlugin, + createCmsGraphQLSchemaPlugin, + ICmsGraphQLSchemaPlugin +} from "~/plugins"; import { createFieldTypePluginRecords } from "~/graphql/schema/createFieldTypePluginRecords"; interface GenerateSchemaPluginsParams { @@ -15,7 +19,7 @@ interface GenerateSchemaPluginsParams { export const generateSchemaPlugins = async ( params: GenerateSchemaPluginsParams -): Promise => { +): Promise => { const { context, models } = params; const { plugins, cms } = context; @@ -49,7 +53,7 @@ export const generateSchemaPlugins = async ( switch (type) { case "manage": { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: createManageSDL({ models, model, @@ -71,7 +75,7 @@ export const generateSchemaPlugins = async ( case "preview": case "read": { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: createReadSDL({ models, model, diff --git a/packages/api-headless-cms/src/index.ts b/packages/api-headless-cms/src/index.ts index 61df7685a6b..1afe7ac5d03 100644 --- a/packages/api-headless-cms/src/index.ts +++ b/packages/api-headless-cms/src/index.ts @@ -18,6 +18,7 @@ import { createFieldConverters } from "~/fieldConverters"; import { createExportGraphQL } from "~/export"; import { createStorageTransform } from "~/storage"; import { createLexicalHTMLRenderer } from "./htmlRenderer/createLexicalHTMLRenderer"; + export * from "./utils/isHeadlessCmsReady"; export * from "./utils/createModelField"; diff --git a/packages/api-headless-cms/src/modelManager/index.ts b/packages/api-headless-cms/src/modelManager/index.ts index 8e46f8b27fb..ff64efa8202 100644 --- a/packages/api-headless-cms/src/modelManager/index.ts +++ b/packages/api-headless-cms/src/modelManager/index.ts @@ -1,11 +1,11 @@ -import { ModelManagerPlugin } from "~/types"; +import { CmsModelManager, ModelManagerPlugin } from "~/types"; import { DefaultCmsModelManager } from "./DefaultCmsModelManager"; const plugin: ModelManagerPlugin = { type: "cms-content-model-manager", name: "content-model-manager-default", - create: async (context, model) => { - return new DefaultCmsModelManager(context, model); + async create(context, model) { + return new DefaultCmsModelManager(context, model) as CmsModelManager; } }; diff --git a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts deleted file mode 100644 index b7b58615bdb..00000000000 --- a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GraphQLSchemaPlugin, GraphQLSchemaPluginConfig } from "@webiny/handler-graphql"; -import { Context } from "@webiny/api/types"; -import { CmsContext } from "~/types"; - -export class CmsGraphQLSchemaPlugin extends GraphQLSchemaPlugin { - public static override type = "cms.graphql.schema"; -} - -export const createCmsGraphQLSchemaPlugin = ( - params: GraphQLSchemaPluginConfig -) => { - return new CmsGraphQLSchemaPlugin(params); -}; diff --git a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts new file mode 100644 index 00000000000..910193ea01a --- /dev/null +++ b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts @@ -0,0 +1,24 @@ +import { + GraphQLSchemaPlugin, + GraphQLSchemaPluginConfig as BaseGraphQLSchemaPluginConfig, + IGraphQLSchemaPlugin +} from "@webiny/handler-graphql"; +import { CmsContext } from "~/types"; + +export type ICmsGraphQLSchemaPlugin = IGraphQLSchemaPlugin; + +export type CmsGraphQLSchemaPluginConfig = + BaseGraphQLSchemaPluginConfig; + +export class CmsGraphQLSchemaPlugin + extends GraphQLSchemaPlugin + implements ICmsGraphQLSchemaPlugin +{ + public static override readonly type = "cms.graphql.schema"; +} + +export const createCmsGraphQLSchemaPlugin = ( + config: CmsGraphQLSchemaPluginConfig +): ICmsGraphQLSchemaPlugin => { + return new CmsGraphQLSchemaPlugin(config); +}; diff --git a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts new file mode 100644 index 00000000000..6de53c5ad1e --- /dev/null +++ b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts @@ -0,0 +1 @@ +export * from "./CmsGraphQLSchemaPlugin"; diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index b788703f0ca..000c57c202e 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -1,6 +1,6 @@ import { Plugin } from "@webiny/plugins/types"; import { I18NContext, I18NLocale } from "@webiny/api-i18n/types"; -import { Context } from "@webiny/api/types"; +import { Context, GenericRecord } from "@webiny/api/types"; import { GraphQLFieldResolver, GraphQLRequestBody, Resolvers } from "@webiny/handler-graphql/types"; import { processRequestBody } from "@webiny/handler-graphql"; import { SecurityPermission } from "@webiny/api-security/types"; @@ -17,6 +17,20 @@ import { CmsModel, CmsModelCreateFromInput, CmsModelCreateInput } from "./model" import { CmsGroup } from "./modelGroup"; import { CmsIdentity } from "./identity"; +export interface CmsError { + message: string; + code: string; + data: GenericRecord; + stack?: string; +} + +export interface CmsError { + message: string; + code: string; + data: GenericRecord; + stack?: string; +} + export type ApiEndpoint = "manage" | "preview" | "read"; export interface HeadlessCms @@ -424,7 +438,7 @@ export interface ModelManagerPlugin extends Plugin { * Create a CmsModelManager for specific type - or new default one. * For reference in how is this plugin run check [contentModelManagerFactory](https://github.com/webiny/webiny-js/blob/f15676/packages/api-headless-cms/src/content/plugins/CRUD/contentModel/contentModelManagerFactory.ts) */ - create: (context: CmsContext, model: CmsModel) => Promise; + create(context: CmsContext, model: CmsModel): Promise>; } /** @@ -658,39 +672,39 @@ export interface CmsEntryUniqueValue { * @category CmsEntry * @category CmsModel */ -export interface CmsModelManager { +export interface CmsModelManager { /** * List only published entries in the content model. */ - listPublished: (params: CmsEntryListParams) => Promise<[CmsEntry[], CmsEntryMeta]>; + listPublished(params: CmsEntryListParams): Promise<[CmsEntry[], CmsEntryMeta]>; /** * List latest entries in the content model. Used for administration. */ - listLatest: (params: CmsEntryListParams) => Promise<[CmsEntry[], CmsEntryMeta]>; + listLatest(params: CmsEntryListParams): Promise<[CmsEntry[], CmsEntryMeta]>; /** * Get a list of published entries by the ID list. */ - getPublishedByIds: (ids: string[]) => Promise; + getPublishedByIds(ids: string[]): Promise[]>; /** * Get a list of the latest entries by the ID list. */ - getLatestByIds: (ids: string[]) => Promise; + getLatestByIds(ids: string[]): Promise[]>; /** * Get an entry filtered by given params. Will always get one. */ - get: (id: string) => Promise; + get(id: string): Promise>; /** * Create an entry. */ - create: (data: CreateCmsEntryInput) => Promise; + create(data: CreateCmsEntryInput & I): Promise>; /** * Update an entry. */ - update: (id: string, data: UpdateCmsEntryInput) => Promise; + update(id: string, data: UpdateCmsEntryInput): Promise>; /** * Delete an entry. */ - delete: (id: string) => Promise; + delete(id: string): Promise; } /** @@ -798,7 +812,7 @@ export interface CmsModelContext { /** * Get a single content model. */ - getModel: (modelId: string) => Promise; + getModel(modelId: string): Promise; /** * Get model to AST converter. */ @@ -806,50 +820,50 @@ export interface CmsModelContext { /** * Get all content models. */ - listModels: () => Promise; + listModels(): Promise; /** * Create a content model. */ - createModel: (data: CmsModelCreateInput) => Promise; + createModel(data: CmsModelCreateInput): Promise; /** * Create a content model from the given model - clone. */ - createModelFrom: (modelId: string, data: CmsModelCreateFromInput) => Promise; + createModelFrom(modelId: string, data: CmsModelCreateFromInput): Promise; /** * Update content model without data validation. Used internally. * @hidden */ - updateModelDirect: (params: CmsModelUpdateDirectParams) => Promise; + updateModelDirect(params: CmsModelUpdateDirectParams): Promise; /** * Update content model. */ - updateModel: (modelId: string, data: CmsModelUpdateInput) => Promise; + updateModel(modelId: string, data: CmsModelUpdateInput): Promise; /** * Delete content model. Should not allow deletion if there are entries connected to it. */ - deleteModel: (modelId: string) => Promise; + deleteModel(modelId: string): Promise; /** * Possibility for users to trigger the model initialization. * They can hook into it and do what ever they want to. * * Primary idea behind this is creating the index, for the code models, in the ES. */ - initializeModel: (modelId: string, data: Record) => Promise; + initializeModel(modelId: string, data: Record): Promise; /** * Get an instance of CmsModelManager for given content modelId. * * @see CmsModelManager */ - getEntryManager: (model: CmsModel | string) => Promise; + getEntryManager(model: CmsModel | string): Promise>; /** * Get all content model managers mapped by modelId. * @see CmsModelManager */ - getEntryManagers: () => Map; + getEntryManagers(): Map; /** * Clear all the model caches. */ - clearModelsCache: () => void; + clearModelsCache(): void; /** * Lifecycle Events */ @@ -1008,6 +1022,7 @@ export interface CmsEntryListWhere { | string | number | boolean + | Date | undefined | string[] | number[] diff --git a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts index dac4f3b5e58..14cb1f7aaea 100644 --- a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts +++ b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts @@ -1,6 +1,6 @@ import { ApiEndpoint, CmsContext, CmsFieldTypePlugins, CmsModel } from "~/types"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; +import { IGraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { GraphQLSchemaDefinition } from "@webiny/handler-graphql/types"; const TYPE_MAP: Record = { @@ -16,11 +16,11 @@ interface CreatePluginCallableParams { } interface CreatePluginCallable { - (params: CreatePluginCallableParams): GraphQLSchemaPlugin; + (params: CreatePluginCallableParams): IGraphQLSchemaPlugin; } const defaultCreatePlugin: CreatePluginCallable = ({ schema, type, fieldType }) => { - const plugin = new CmsGraphQLSchemaPlugin(schema); + const plugin = createCmsGraphQLSchemaPlugin(schema); plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldType}`; return plugin; }; @@ -34,7 +34,7 @@ interface Params { export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { const { models, fieldTypePlugins, type, createPlugin = defaultCreatePlugin } = params; - const plugins: CmsGraphQLSchemaPlugin[] = []; + const plugins: ICmsGraphQLSchemaPlugin[] = []; for (const key in fieldTypePlugins) { const fieldTypePlugin = fieldTypePlugins[key]; if (!TYPE_MAP[type] || !fieldTypePlugin[TYPE_MAP[type]]) { @@ -47,7 +47,7 @@ export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { } const schema = createSchema({ models }); - // const plugin = new CmsGraphQLSchemaPlugin(schema); + // const plugin = createCmsGraphQLSchemaPlugin(schema); // plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldTypePlugin.fieldType}`; const plugin = createPlugin({ schema, diff --git a/packages/api-locking-mechanism/.babelrc.js b/packages/api-locking-mechanism/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-locking-mechanism/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-locking-mechanism/LICENSE b/packages/api-locking-mechanism/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-locking-mechanism/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-locking-mechanism/README.md b/packages/api-locking-mechanism/README.md new file mode 100644 index 00000000000..9d9a47469bd --- /dev/null +++ b/packages/api-locking-mechanism/README.md @@ -0,0 +1,10 @@ +# @webiny/api-locking-mechanism +[![](https://img.shields.io/npm/dw/@webiny/api-locking-mechanism.svg)](https://www.npmjs.com/package/@webiny/api-locking-mechanism) +[![](https://img.shields.io/npm/v/@webiny/api-locking-mechanism.svg)](https://www.npmjs.com/package/@webiny/api-locking-mechanism) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +## Install +``` +yarn add @webiny/api-locking-mechanism +``` diff --git a/packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts b/packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts new file mode 100644 index 00000000000..1100705481a --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts @@ -0,0 +1,27 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("get lock record", () => { + const { getLockRecordQuery } = useGraphQLHandler(); + + it("should throw an error on non-existing lock - getLockRecord", async () => { + const [response] = await getLockRecordQuery({ + id: "nonExistingId", + type: "author" + }); + + expect(response).toMatchObject({ + data: { + lockingMechanism: { + getLockRecord: { + data: null, + error: { + code: "NOT_FOUND", + message: "Lock record not found.", + data: null + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/getLockedEntryLockRecord.test.ts b/packages/api-locking-mechanism/__tests__/graphql/getLockedEntryLockRecord.test.ts new file mode 100644 index 00000000000..358c5ca414f --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/getLockedEntryLockRecord.test.ts @@ -0,0 +1,88 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createIdentity } from "~tests/helpers/identity"; + +describe("get locked entry lock record", () => { + const { + lockEntryMutation, + getLockedEntryLockRecordQuery: creatorGetLockedEntryLockRecordQuery + } = useGraphQLHandler(); + + const { getLockedEntryLockRecordQuery } = useGraphQLHandler({ + identity: createIdentity({ + id: "anotherIdentityId", + displayName: "Another Identity", + type: "admin" + }) + }); + + it("should return null for non existing lock record - getLockedEntryLockRecord", async () => { + const [response] = await getLockedEntryLockRecordQuery({ + id: "nonExistingId", + type: "author" + }); + + expect(response).toMatchObject({ + data: { + lockingMechanism: { + getLockedEntryLockRecord: { + data: null, + error: null + } + } + } + }); + }); + + it("should return a record for a locked entry", async () => { + const [lockResult] = await lockEntryMutation({ + id: "aTestId#0001", + type: "aTestType" + }); + + expect(lockResult).toMatchObject({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "aTestId" + }, + error: null + } + } + } + }); + + const [shouldNotBeLockedResponse] = await creatorGetLockedEntryLockRecordQuery({ + id: "aTestId#0001", + type: "author" + }); + expect(shouldNotBeLockedResponse).toMatchObject({ + data: { + lockingMechanism: { + getLockedEntryLockRecord: { + data: null, + error: null + } + } + } + }); + + const [shouldBeLockedResponse] = await getLockedEntryLockRecordQuery({ + id: "aTestId#0001", + type: "author" + }); + + expect(shouldBeLockedResponse).toMatchObject({ + data: { + lockingMechanism: { + getLockedEntryLockRecord: { + data: { + id: "aTestId" + }, + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts b/packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts new file mode 100644 index 00000000000..f0c78ddfdd8 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts @@ -0,0 +1,23 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("is entry locked", () => { + const { isEntryLockedQuery } = useGraphQLHandler(); + + it("should return false on checking if entry is locked", async () => { + const [response] = await isEntryLockedQuery({ + id: "someId", + type: "cms#author" + }); + + expect(response).toEqual({ + data: { + lockingMechanism: { + isEntryLocked: { + data: false, + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts b/packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts new file mode 100644 index 00000000000..52c85ea803a --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts @@ -0,0 +1,100 @@ +import { createIdentity } from "~tests/helpers/identity"; +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("list lock records", () => { + const { lockEntryMutation } = useGraphQLHandler(); + + const anotherUserGraphQL = useGraphQLHandler({ + identity: createIdentity({ + displayName: "Jane Doe", + id: "id-87654321", + type: "admin" + }) + }); + + it("should list lock records - none found", async () => { + const [result] = await anotherUserGraphQL.listLockRecordsQuery(); + + expect(result).toEqual({ + data: { + lockingMechanism: { + listLockRecords: { + data: [], + error: null + } + } + } + }); + }); + + it("should list all locked records", async () => { + await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + + await lockEntryMutation({ + id: "someOtherId#0001", + type: "cms#author" + }); + + const [result] = await anotherUserGraphQL.listLockRecordsQuery(); + + expect(result.data.lockingMechanism.listLockRecords.data).toHaveLength(2); + expect(result).toMatchObject({ + data: { + lockingMechanism: { + listLockRecords: { + data: [ + { + id: "someOtherId" + }, + { + id: "someId" + } + ], + error: null + } + } + } + }); + }); + + it("should list filtered locked records", async () => { + await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + + await lockEntryMutation({ + id: "someOtherId#0001", + type: "cms#author" + }); + + const [resultIdIn] = await anotherUserGraphQL.listLockRecordsQuery({ + where: { + id_in: ["someId"], + type: "cms#author" + } + }); + + expect(resultIdIn.data.lockingMechanism.listLockRecords.data).toHaveLength(2); + expect(resultIdIn).toMatchObject({ + data: { + lockingMechanism: { + listLockRecords: { + data: [ + { + id: "someOtherId" + }, + { + id: "someId" + } + ], + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts b/packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts new file mode 100644 index 00000000000..8ecb2835643 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts @@ -0,0 +1,141 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createIdentity } from "~tests/helpers/identity"; + +describe("lock entry", () => { + const { getLockRecordQuery, isEntryLockedQuery, lockEntryMutation } = useGraphQLHandler(); + + it("should create a lock record", async () => { + const [isEntryLockedResponse] = await isEntryLockedQuery({ + id: "someId", + type: "cms#author" + }); + + expect(isEntryLockedResponse).toEqual({ + data: { + lockingMechanism: { + isEntryLocked: { + data: false, + error: null + } + } + } + }); + + const [response] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + + expect(response).toEqual({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [getResponse] = await getLockRecordQuery({ + id: "someId", + type: "cms#author" + }); + expect(getResponse).toEqual({ + data: { + lockingMechanism: { + getLockRecord: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + }); + + it("should return error if entry is already locked", async () => { + const anotherUserGraphQL = useGraphQLHandler({ + identity: createIdentity({ + displayName: "Jane Doe", + id: "id-87654321", + type: "admin" + }) + }); + const [firstLockResponse] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + + expect(firstLockResponse).toEqual({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [response] = await anotherUserGraphQL.lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + expect(response).toEqual({ + data: { + lockingMechanism: { + lockEntry: { + data: null, + error: { + code: "ENTRY_ALREADY_LOCKED", + message: "Entry is already locked for editing.", + data: { + id: "someId#0001", + type: "cms#author" + } + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts b/packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts new file mode 100644 index 00000000000..332b96df282 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts @@ -0,0 +1,106 @@ +import { ILockingMechanismLockRecordActionType } from "~/types"; +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createIdentity } from "~tests/helpers/identity"; + +const secondIdentity = createIdentity({ + displayName: "Jane Doe", + id: "id-87654321", + type: "admin" +}); + +describe("request entry unlock", () => { + const { lockEntryMutation } = useGraphQLHandler(); + + const { unlockEntryRequestMutation } = useGraphQLHandler({ + identity: secondIdentity + }); + + it("should request unlocking of a locked entry", async () => { + const [lockEntryResponse] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + expect(lockEntryResponse).toMatchObject({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [unlockEntryRequestResponse] = await unlockEntryRequestMutation({ + type: "cms#author", + id: "someId#0001" + }); + + expect(unlockEntryRequestResponse).toEqual({ + data: { + lockingMechanism: { + unlockEntryRequest: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [ + { + type: ILockingMechanismLockRecordActionType.requested, + message: null, + createdBy: secondIdentity, + createdOn: expect.toBeDateString() + } + ] + }, + error: null + } + } + } + }); + + const [unlockEntryRequestErrorResponse] = await unlockEntryRequestMutation({ + type: "cms#author", + id: "someId#0001" + }); + + expect(unlockEntryRequestErrorResponse).toMatchObject({ + data: { + lockingMechanism: { + unlockEntryRequest: { + data: null, + error: { + code: "UNLOCK_REQUEST_ALREADY_SENT", + data: { + id: "someId#0001", + type: "cms#author" + }, + message: "Unlock request already sent." + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts b/packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts new file mode 100644 index 00000000000..7e1ed7863eb --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts @@ -0,0 +1,150 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createIdentity, getSecurityIdentity } from "~tests/helpers/identity"; + +describe("unlock entry", () => { + const { getLockRecordQuery, unlockEntryMutation, lockEntryMutation } = useGraphQLHandler(); + + const anotherUserGraphQL = useGraphQLHandler({ + identity: createIdentity({ + displayName: "Jane Doe", + id: "id-87654321", + type: "admin" + }) + }); + + it("should unlock a locked entry", async () => { + /** + * Even if there is no lock record, we should act as the entry was unlocked. + */ + const [unlockResponseNoEntry] = await unlockEntryMutation({ + type: "cms#author", + id: "someId#0001" + }); + expect(unlockResponseNoEntry).toEqual({ + data: { + lockingMechanism: { + unlockEntry: { + data: null, + error: { + code: "LOCK_RECORD_NOT_FOUND", + data: { + id: "someId#0001", + type: "cms#author" + }, + message: "Lock Record not found." + } + } + } + } + }); + + const [lockEntryResponse] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + expect(lockEntryResponse).toMatchObject({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author" + }, + error: null + } + } + } + }); + + const [getResponse] = await getLockRecordQuery({ + id: "someId", + type: "cms#author" + }); + expect(getResponse).toEqual({ + data: { + lockingMechanism: { + getLockRecord: { + data: { + id: "someId", + lockedBy: getSecurityIdentity(), + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [isEntryLockedResponse] = await anotherUserGraphQL.isEntryLockedQuery({ + id: "someId#0001", + type: "cms#author" + }); + expect(isEntryLockedResponse).toEqual({ + data: { + lockingMechanism: { + isEntryLocked: { + data: true, + error: null + } + } + } + }); + + const [unlockResponse] = await unlockEntryMutation({ + type: "cms#author", + id: "someId#0001" + }); + expect(unlockResponse).toEqual({ + data: { + lockingMechanism: { + unlockEntry: { + data: { + actions: [], + id: "someId", + lockedBy: getSecurityIdentity(), + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString(), + expiresOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author" + }, + error: null + } + } + } + }); + + const [getResponseAfterUnlock] = await getLockRecordQuery({ + id: "someId", + type: "cms#author" + }); + expect(getResponseAfterUnlock).toMatchObject({ + data: { + lockingMechanism: { + getLockRecord: { + data: null, + error: { + code: "NOT_FOUND", + data: null, + message: "Lock record not found." + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/updateEntryLock.test.ts b/packages/api-locking-mechanism/__tests__/graphql/updateEntryLock.test.ts new file mode 100644 index 00000000000..c9d57338179 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/updateEntryLock.test.ts @@ -0,0 +1,109 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createIdentity } from "~tests/helpers/identity"; + +describe("update entry lock", () => { + const { updateEntryLockMutation, lockEntryMutation } = useGraphQLHandler(); + + it("should update the lock record", async () => { + const [lockResult] = await lockEntryMutation({ + id: "aTestId#0001", + type: "aTestType" + }); + expect(lockResult).toMatchObject({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "aTestId", + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString() + }, + error: null + } + } + } + }); + + const initialLockedOn = lockResult.data.lockingMechanism.lockEntry.data!.lockedOn; + const initialUpdatedOn = lockResult.data.lockingMechanism.lockEntry.data!.updatedOn; + + expect(initialLockedOn).toEqual(initialUpdatedOn); + + const [result] = await updateEntryLockMutation({ + id: "aTestId#0001", + type: "aTestType" + }); + expect(result).toMatchObject({ + data: { + lockingMechanism: { + updateEntryLock: { + data: { + id: "aTestId", + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString() + }, + error: null + } + } + } + }); + const updatedOn = result.data.lockingMechanism.updateEntryLock.data!.updatedOn; + expect(new Date(updatedOn).getTime()).toBeGreaterThan(new Date(initialUpdatedOn).getTime()); + }); + + it("should return an error if lock record is not of the same user trying to update it", async () => { + await lockEntryMutation({ + id: "aTestId#0001", + type: "aTestType" + }); + + const anotherUserMethods = useGraphQLHandler({ + identity: createIdentity({ + id: "anotherUserId", + displayName: "Another User", + type: "admin" + }) + }); + + const [result] = await anotherUserMethods.updateEntryLockMutation({ + id: "aTestId#0001", + type: "aTestType" + }); + expect(result).toEqual({ + data: { + lockingMechanism: { + updateEntryLock: { + data: null, + error: { + code: "LOCK_UPDATE_ERROR", + data: null, + message: "Cannot update lock record. Record is locked by another user." + } + } + } + } + }); + }); + + it("should return a new lock if no existing lock record", async () => { + const [result] = await updateEntryLockMutation({ + id: "aTestId#0001", + type: "aTestType" + }); + + expect(result).toMatchObject({ + data: { + lockingMechanism: { + updateEntryLock: { + data: { + id: "aTestId", + lockedOn: expect.toBeDateString(), + updatedOn: expect.toBeDateString() + }, + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts b/packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts new file mode 100644 index 00000000000..3508d8eaed7 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts @@ -0,0 +1,250 @@ +import { ILockingMechanismLockRecord } from "~/types"; +import { CmsError } from "@webiny/api-headless-cms/types"; + +export const LOCK_ERROR = /* GraphQL */ ` + error { + message + code + data + } + `; + +export const LOCK_RECORD = /* GraphQL */ ` + id + targetId + type + lockedBy { + id + displayName + type + } + lockedOn + updatedOn + expiresOn + actions { + type + createdOn + createdBy { + id + displayName + type + } + message + } +`; + +export interface IIsEntryLockedGraphQlVariables { + id: string; + type: string; +} + +export interface IIsEntryLockedGraphQlResponse { + lockingMechanism: { + isEntryLocked: { + data?: boolean; + error?: CmsError; + }; + }; +} + +export const IS_ENTRY_LOCKED_QUERY = /* GraphQL */ ` + query IsEntryLocked($id: ID!, $type: String!) { + lockingMechanism { + isEntryLocked(id: $id, type: $type) { + data + ${LOCK_ERROR} + } + } + } +`; + +export interface IListLockRecordsGraphQlVariables { + limit?: number; + after?: string; + sort?: string[]; + where?: Record; +} + +export interface IListLockRecordsGraphQlResponse { + lockingMechanism: { + listLockRecords: { + data?: ILockingMechanismLockRecord[]; + error?: CmsError; + }; + }; +} + +export const LIST_LOCK_RECORDS_QUERY = /* GraphQL */ ` + query ListLockRecords { + lockingMechanism { + listLockRecords { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IGetLockRecordGraphQlVariables { + id: string; + type: string; +} + +export interface IGetLockRecordGraphQlResponse { + lockingMechanism: { + getLockRecord: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const GET_LOCK_RECORD_QUERY = /* GraphQL */ ` + query GetLockRecord($id: ID!, $type: String!) { + lockingMechanism { + getLockRecord(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IGetLockedEntryLockRecordGraphQlVariables { + id: string; + type: string; +} + +export interface IGetLockedEntryLockRecordGraphQlResponse { + lockingMechanism: { + getLockRecord: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const GET_LOCKED_ENTRY_LOCK_RECORD_QUERY = /* GraphQL */ ` + query GetLockedEntryLockRecord($id: ID!, $type: String!) { + lockingMechanism { + getLockedEntryLockRecord(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface ILockEntryGraphQlVariables { + id: string; + type: string; +} + +export interface ILockEntryGraphQlResponse { + lockingMechanism: { + lockEntry: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const LOCK_ENTRY_MUTATION = /* GraphQL */ ` + mutation LockEntry($id: ID!, $type: String!) { + lockingMechanism { + lockEntry(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IUpdateEntryLockGraphQlVariables { + id: string; + type: string; +} + +export interface IUpdateEntryLockGraphQlResponse { + lockingMechanism: { + updateEntryLock: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export interface IUnlockEntryGraphQlVariables { + id: string; + type: string; +} + +export const UPDATE_ENTRY_LOCK_MUTATION = /* GraphQL */ ` + mutation UpdateEntryLock($id: ID!, $type: String!) { + lockingMechanism { + updateEntryLock(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IUnlockEntryGraphQlResponse { + lockingMechanism: { + unlockEntry: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const UNLOCK_ENTRY_MUTATION = /* GraphQL */ ` + mutation UnlockEntry($id: ID!, $type: String!) { + lockingMechanism { + unlockEntry(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IUnlockEntryRequestGraphQlVariables { + id: string; + type: string; +} + +export interface IUnlockEntryRequestGraphQlResponse { + lockingMechanism: { + unlockEntryRequest: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const UNLOCK_ENTRY_REQUEST_MUTATION = /* GraphQL */ ` + mutation UnlockEntryRequest($id: ID!, $type: String!) { + lockingMechanism { + unlockEntryRequest(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; diff --git a/packages/api-locking-mechanism/__tests__/helpers/identity.ts b/packages/api-locking-mechanism/__tests__/helpers/identity.ts new file mode 100644 index 00000000000..95d40606f51 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/identity.ts @@ -0,0 +1,18 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; + +const defaultIdentity = { + id: "id-12345678", + displayName: "John Doe", + type: "admin" +}; + +export const getSecurityIdentity = () => { + return { ...defaultIdentity }; +}; + +export const createIdentity = (identity?: SecurityIdentity) => { + if (!identity) { + return getSecurityIdentity(); + } + return { ...identity }; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/locales.ts b/packages/api-locking-mechanism/__tests__/helpers/locales.ts new file mode 100644 index 00000000000..022859df9d6 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/locales.ts @@ -0,0 +1,27 @@ +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~/types"; + +export const createDummyLocales = () => { + return new ContextPlugin(async context => { + const { i18n, security } = context; + + await security.authenticate(""); + + await security.withoutAuthorization(async () => { + const [items] = await i18n.locales.listLocales({ + where: {} + }); + if (items.length > 0) { + return; + } + await i18n.locales.createLocale({ + code: "en-US", + default: true + }); + await i18n.locales.createLocale({ + code: "de-DE", + default: true + }); + }); + }); +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/permissions.ts b/packages/api-locking-mechanism/__tests__/helpers/permissions.ts new file mode 100644 index 00000000000..cdcedc15a05 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/permissions.ts @@ -0,0 +1,47 @@ +export interface PermissionsArg { + name: string; + locales?: string[]; + models?: Record; + groups?: Record; + rwd?: string; + pw?: string; + own?: boolean; + _src?: string; +} + +export const createPermissions = (permissions?: PermissionsArg[]): PermissionsArg[] => { + if (permissions) { + return permissions; + } + return [ + { + name: "cms.settings" + }, + { + name: "cms.contentModel", + rwd: "rwd" + }, + { + name: "cms.contentModelGroup", + rwd: "rwd" + }, + { + name: "cms.contentEntry", + rwd: "rwd", + pw: "rcpu" + }, + { + name: "cms.endpoint.read" + }, + { + name: "cms.endpoint.manage" + }, + { + name: "cms.endpoint.preview" + }, + { + name: "content.i18n", + locales: ["en-US", "de-DE"] + } + ]; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/plugins.ts b/packages/api-locking-mechanism/__tests__/helpers/plugins.ts new file mode 100644 index 00000000000..fea3312aa8d --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/plugins.ts @@ -0,0 +1,116 @@ +import apiKeyAuthentication from "@webiny/api-security/plugins/apiKeyAuthentication"; +import apiKeyAuthorization from "@webiny/api-security/plugins/apiKeyAuthorization"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import { ApiKey, SecurityIdentity } from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import { Plugin, PluginCollection } from "@webiny/plugins/types"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { Context } from "~/types"; +import { createPermissions, PermissionsArg } from "./permissions"; +import { createDummyLocales } from "./locales"; +import { createLockingMechanism } from "~/index"; + +export interface CreateHandlerCoreParams { + setupTenancyAndSecurityGraphQL?: boolean; + permissions?: PermissionsArg[]; + identity?: SecurityIdentity; + topPlugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + plugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + bottomPlugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; +} + +export const createHandlerCore = (params: CreateHandlerCoreParams) => { + const tenant = { + id: "root", + name: "Root", + parent: null + }; + const { + permissions, + identity, + plugins = [], + topPlugins = [], + bottomPlugins = [], + setupTenancyAndSecurityGraphQL + } = params; + + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + + return { + storageOperations: cmsStorage.storageOperations, + tenant, + plugins: [ + topPlugins, + createWcpContext(), + new ContextPlugin(async context => { + const wcp = context.wcp; + context.wcp.ensureCanUseFeature = featureId => { + if (featureId !== "recordLocking") { + return wcp.ensureCanUseFeature(featureId); + } + return true; + }; + context.wcp.canUseRecordLocking = () => { + return true; + }; + }), + ...cmsStorage.plugins, + ...createTenancyAndSecurity({ + setupGraphQL: setupTenancyAndSecurityGraphQL, + permissions: createPermissions(permissions), + identity + }), + { + type: "context", + name: "context-security-tenant", + async apply(context) { + context.security.getApiKeyByToken = async ( + token: string + ): Promise => { + if (!token || token !== "aToken") { + return null; + } + const apiKey = "a1234567890"; + return { + id: apiKey, + name: apiKey, + tenant: tenant.id, + permissions: identity?.permissions || [], + token, + createdBy: { + id: "test", + displayName: "test", + type: "admin" + }, + description: "test", + createdOn: new Date().toISOString(), + webinyVersion: context.WEBINY_VERSION + }; + }; + } + } as ContextPlugin, + apiKeyAuthentication({ identityType: "api-key" }), + apiKeyAuthorization({ identityType: "api-key" }), + i18nContext(), + i18nStorage.storageOperations, + createDummyLocales(), + mockLocalesPlugins(), + graphQLHandlerPlugins(), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + createLockingMechanism(), + plugins, + graphQLHandlerPlugins(), + bottomPlugins + ] + }; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts b/packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts new file mode 100644 index 00000000000..ce58ad2f694 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts @@ -0,0 +1,69 @@ +import { Plugin } from "@webiny/plugins/Plugin"; +import { createTenancyContext, createTenancyGraphQL } from "@webiny/api-tenancy"; +import { createSecurityContext, createSecurityGraphQL } from "@webiny/api-security"; +import { + SecurityIdentity, + SecurityPermission, + SecurityStorageOperations +} from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { BeforeHandlerPlugin } from "@webiny/handler"; +import { Context } from "~/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { TenancyStorageOperations, Tenant } from "@webiny/api-tenancy/types"; + +interface Config { + setupGraphQL?: boolean; + permissions: SecurityPermission[]; + identity?: SecurityIdentity | null; +} + +export const defaultIdentity: SecurityIdentity = { + id: "id-12345678", + type: "admin", + displayName: "John Doe" +}; + +export const createTenancyAndSecurity = ({ + setupGraphQL, + permissions, + identity +}: Config): Plugin[] => { + const tenancyStorage = getStorageOps("tenancy"); + const securityStorage = getStorageOps("security"); + + return [ + createTenancyContext({ storageOperations: tenancyStorage.storageOperations }), + setupGraphQL ? createTenancyGraphQL() : null, + createSecurityContext({ storageOperations: securityStorage.storageOperations }), + setupGraphQL ? createSecurityGraphQL() : null, + new ContextPlugin(context => { + context.tenancy.setCurrentTenant({ + id: "root", + name: "Root", + webinyVersion: context.WEBINY_VERSION + } as unknown as Tenant); + + context.security.addAuthenticator(async () => { + return identity || defaultIdentity; + }); + + context.security.addAuthorizer(async () => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return null; + } + + return permissions || [{ name: "*" }]; + }); + }), + new BeforeHandlerPlugin(context => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return context.security.authenticate(headers["authorization"]); + } + + return context.security.authenticate(""); + }) + ].filter(Boolean) as Plugin[]; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts b/packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts new file mode 100644 index 00000000000..dd40b398abe --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts @@ -0,0 +1,139 @@ +import { getIntrospectionQuery } from "graphql"; +import { createHandler } from "@webiny/handler-aws"; +import { PluginsContainer } from "@webiny/plugins/types"; +import { createHandlerCore, CreateHandlerCoreParams } from "./plugins"; +import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; +import { defaultIdentity } from "./tenancySecurity"; +import { + GET_LOCK_RECORD_QUERY, + GET_LOCKED_ENTRY_LOCK_RECORD_QUERY, + IGetLockedEntryLockRecordGraphQlResponse, + IGetLockedEntryLockRecordGraphQlVariables, + IGetLockRecordGraphQlResponse, + IGetLockRecordGraphQlVariables, + IIsEntryLockedGraphQlResponse, + IIsEntryLockedGraphQlVariables, + IListLockRecordsGraphQlResponse, + IListLockRecordsGraphQlVariables, + ILockEntryGraphQlResponse, + ILockEntryGraphQlVariables, + IS_ENTRY_LOCKED_QUERY, + IUnlockEntryGraphQlResponse, + IUnlockEntryGraphQlVariables, + IUnlockEntryRequestGraphQlResponse, + IUnlockEntryRequestGraphQlVariables, + IUpdateEntryLockGraphQlResponse, + IUpdateEntryLockGraphQlVariables, + LIST_LOCK_RECORDS_QUERY, + LOCK_ENTRY_MUTATION, + UNLOCK_ENTRY_MUTATION, + UNLOCK_ENTRY_REQUEST_MUTATION, + UPDATE_ENTRY_LOCK_MUTATION +} from "./graphql/lockingMechanism"; + +export type GraphQLHandlerParams = CreateHandlerCoreParams; + +export interface IInvokeResult { + data: T; +} + +export interface InvokeParams { + httpMethod?: "POST" | "GET" | "OPTIONS"; + body?: { + query: string; + variables?: Record; + }; + headers?: Record; +} + +export const useGraphQLHandler = (params: GraphQLHandlerParams = {}) => { + const { identity } = params; + + const core = createHandlerCore(params); + + const plugins = new PluginsContainer(core.plugins); + + const handler = createHandler({ + plugins: plugins.all(), + debug: false + }); + + const invoke = async ({ + httpMethod = "POST", + body, + headers = {}, + ...rest + }: InvokeParams): Promise<[IInvokeResult, any]> => { + const response = await handler( + { + path: "/graphql", + httpMethod, + headers: { + ["x-tenant"]: "root", + ["Content-Type"]: "application/json", + ...headers + }, + body: JSON.stringify(body), + ...rest + } as unknown as APIGatewayEvent, + {} as unknown as LambdaContext + ); + // The first element is the response body, and the second is the raw response. + return [JSON.parse(response.body || "{}"), response]; + }; + + return { + handler, + invoke, + tenant: core.tenant, + identity: identity || defaultIdentity, + plugins, + storageOperations: core.storageOperations, + async introspect() { + return invoke({ body: { query: getIntrospectionQuery() } }); + }, + /** + * Locking Mechanism + */ + async listLockRecordsQuery(variables?: IListLockRecordsGraphQlVariables) { + return invoke({ + body: { query: LIST_LOCK_RECORDS_QUERY, variables } + }); + }, + async getLockRecordQuery(variables: IGetLockRecordGraphQlVariables) { + return invoke({ + body: { query: GET_LOCK_RECORD_QUERY, variables } + }); + }, + async getLockedEntryLockRecordQuery(variables: IGetLockedEntryLockRecordGraphQlVariables) { + return invoke({ + body: { query: GET_LOCKED_ENTRY_LOCK_RECORD_QUERY, variables } + }); + }, + async isEntryLockedQuery(variables: IIsEntryLockedGraphQlVariables) { + return invoke({ + body: { query: IS_ENTRY_LOCKED_QUERY, variables } + }); + }, + async lockEntryMutation(variables: ILockEntryGraphQlVariables) { + return invoke({ + body: { query: LOCK_ENTRY_MUTATION, variables } + }); + }, + async updateEntryLockMutation(variables: IUpdateEntryLockGraphQlVariables) { + return invoke({ + body: { query: UPDATE_ENTRY_LOCK_MUTATION, variables } + }); + }, + async unlockEntryMutation(variables: IUnlockEntryGraphQlVariables) { + return invoke({ + body: { query: UNLOCK_ENTRY_MUTATION, variables } + }); + }, + async unlockEntryRequestMutation(variables: IUnlockEntryRequestGraphQlVariables) { + return invoke({ + body: { query: UNLOCK_ENTRY_REQUEST_MUTATION, variables } + }); + } + }; +}; diff --git a/packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts new file mode 100644 index 00000000000..fc4c84b516f --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts @@ -0,0 +1,75 @@ +import { IsEntryLockedUseCase } from "~/useCases/IsEntryLocked/IsEntryLockedUseCase"; +import { WebinyError } from "@webiny/error"; +import { ILockingMechanismLockRecord } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { isLockedFactory } from "~/utils/isLockedFactory"; +import { createIdentity } from "~tests/helpers/identity"; + +describe("is entry locked use case", () => { + const timeout = 600000; + const isLocked = isLockedFactory(timeout); + + it("should return false if lock record is not found - object param", async () => { + const useCase = new IsEntryLockedUseCase({ + getLockRecordUseCase: { + async execute() { + throw new NotFoundError(); + } + }, + isLocked, + getIdentity: createIdentity + }); + + const result = await useCase.execute({ + id: "aTestId#0001", + type: "aTestType" + }); + + expect(result).toBe(false); + }); + + it("should return false if lock record is not locked", async () => { + const useCase = new IsEntryLockedUseCase({ + getLockRecordUseCase: { + async execute() { + return { + lockedOn: new Date("2020-01-01"), + lockedBy: createIdentity() + } as unknown as ILockingMechanismLockRecord; + } + }, + isLocked, + getIdentity: createIdentity + }); + + const result = await useCase.execute({ + id: "aTestId#0001", + type: "aTestType" + }); + + expect(result).toBe(false); + }); + + it("should throw an error on error in getLockRecordUseCase", async () => { + expect.assertions(1); + + const useCase = new IsEntryLockedUseCase({ + getLockRecordUseCase: { + async execute() { + throw new WebinyError("Testing error.", "TESTING_ERROR"); + } + }, + isLocked, + getIdentity: createIdentity + }); + + try { + await useCase.execute({ + id: "aTestId#0001", + type: "aTestType" + }); + } catch (ex) { + expect(ex).toEqual(new WebinyError("Testing error.", "TESTING_ERROR")); + } + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/kickOutCurrentUser.test.ts b/packages/api-locking-mechanism/__tests__/useCase/kickOutCurrentUser.test.ts new file mode 100644 index 00000000000..0b0d5690eae --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/kickOutCurrentUser.test.ts @@ -0,0 +1,52 @@ +import { KickOutCurrentUserUseCase } from "~/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase"; +import { IWebsocketsContextObject } from "@webiny/api-websockets"; +import { createIdentity } from "~tests/helpers/identity"; +import { ILockingMechanismLockRecord } from "~/types"; + +describe("kick out current user", () => { + it("should send message via websockets to kick out current user", async () => { + const websocketsSend = jest.fn(async () => { + return; + }); + + const kickOutUserUseCase = new KickOutCurrentUserUseCase({ + getIdentity: () => { + return { + id: "identity-id", + displayName: "identity-display-name", + type: "identity-type" + }; + }, + getWebsockets: () => { + return { + send: websocketsSend + } as unknown as IWebsocketsContextObject; + } + }); + + const mockRecord = { + id: "aTestId#0001", + lockedOn: new Date("2020-01-01"), + lockedBy: createIdentity(), + toObject: () => { + return { + id: "aTestId#0001", + lockedOn: new Date("2020-01-01"), + lockedBy: createIdentity() + }; + } + } as unknown as ILockingMechanismLockRecord; + + let error: Error | null = null; + try { + await kickOutUserUseCase.execute({ + ...mockRecord + }); + } catch (ex) { + error = ex; + } + expect(error).toBeNull(); + + expect(websocketsSend).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts new file mode 100644 index 00000000000..e72eabce271 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts @@ -0,0 +1,66 @@ +import { LockEntryUseCase } from "~/useCases/LockEntryUseCase/LockEntryUseCase"; +import { WebinyError } from "@webiny/error"; +import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; +import { ILockingMechanismModelManager } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; + +describe("lock entry use case", () => { + it("should throw an error on isEntryLockedUseCase.execute", async () => { + expect.assertions(1); + const useCase = new LockEntryUseCase({ + isEntryLockedUseCase: { + execute: async () => { + throw new WebinyError("Trying out an error", "TRYING_OUT_ERROR", {}); + } + } as unknown as IIsEntryLockedUseCase, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + try { + await useCase.execute({ + id: "id1", + type: "aType" + }); + } catch (ex) { + expect(ex).toEqual(new WebinyError("Trying out an error", "TRYING_OUT_ERROR", {})); + } + }); + + it("should throw an error on creating a lock record", async () => { + expect.assertions(1); + const useCase = new LockEntryUseCase({ + isEntryLockedUseCase: { + execute: async () => { + throw new NotFoundError(); + } + } as unknown as IIsEntryLockedUseCase, + getManager: async () => { + return { + create: async () => { + throw new WebinyError( + "Trying out an error on manager.create.", + "TRYING_OUT_ERROR", + {} + ); + } + } as unknown as ILockingMechanismModelManager; + } + }); + + try { + await useCase.execute({ + id: "id1", + type: "aType" + }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError( + "Could not lock entry: Trying out an error on manager.create.", + "TRYING_OUT_ERROR", + {} + ) + ); + } + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts new file mode 100644 index 00000000000..94c4a59e6a1 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts @@ -0,0 +1,101 @@ +import { UnlockEntryRequestUseCase } from "~/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { getSecurityIdentity } from "~tests/helpers/identity"; +import { ILockingMechanismModelManager } from "~/types"; +import { WebinyError } from "@webiny/error"; + +describe("unlock entry request use case", () => { + it("should throw an error on missing lock record", async () => { + expect.assertions(1); + const useCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase: { + execute: async () => { + return null; + } + } as unknown as IGetLockRecordUseCase, + getIdentity: getSecurityIdentity, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + + try { + await useCase.execute({ id: "id", type: "type" }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError("Entry is not locked.", "ENTRY_NOT_LOCKED", { + id: "id", + type: "type" + }) + ); + } + }); + + it("should throw an error if current user did not start the unlock request", async () => { + expect.assertions(1); + const useCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase: { + execute: async () => { + return { + getUnlockRequested() { + return { + createdBy: { + id: "other-user-id" + } + }; + } + }; + } + } as unknown as IGetLockRecordUseCase, + getIdentity: getSecurityIdentity, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + + try { + await useCase.execute({ id: "id", type: "type" }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError("Unlock request already sent.", "UNLOCK_REQUEST_ALREADY_SENT", { + id: "id", + type: "type", + identity: { + id: "other-user-id" + } + }) + ); + } + }); + + it("should return the lock record if unlock request was already approved", async () => { + expect.assertions(1); + const useCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase: { + execute: async () => { + return { + id: "wby-lm-aTestIdValue", + getUnlockRequested() { + return { + createdBy: getSecurityIdentity() + }; + }, + getUnlockApproved() { + return {}; + }, + getUnlockDenied() { + return null; + } + }; + } + } as unknown as IGetLockRecordUseCase, + getIdentity: getSecurityIdentity, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + + const result = await useCase.execute({ id: "aTestIdValue#0001", type: "type" }); + expect(result.id).toEqual("wby-lm-aTestIdValue"); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts new file mode 100644 index 00000000000..1e591fd4c45 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts @@ -0,0 +1,40 @@ +import { UnlockEntryUseCase } from "~/useCases/UnlockEntryUseCase/UnlockEntryUseCase"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { WebinyError } from "@webiny/error"; +import { createIdentity } from "~tests/helpers/identity"; + +describe("unlock entry use case", () => { + it("should throw an error on unlocking an entry", async () => { + expect.assertions(1); + + const useCase = new UnlockEntryUseCase({ + getLockRecordUseCase: { + execute: async () => { + return { + lockedBy: createIdentity() + }; + } + } as unknown as IGetLockRecordUseCase, + async getManager() { + throw new WebinyError("Testing error.", "TESTING_ERROR"); + }, + getIdentity: createIdentity, + hasFullAccess: async () => { + return true; + }, + kickOutCurrentUserUseCase: { + async execute(): Promise { + return; + } + } + }); + + try { + await useCase.execute({ id: "id", type: "type" }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError("Could not unlock entry: Testing error.", "UNLOCK_ENTRY_ERROR") + ); + } + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/utils/convertWhereCondition.test.ts b/packages/api-locking-mechanism/__tests__/utils/convertWhereCondition.test.ts new file mode 100644 index 00000000000..86888d12ed7 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/utils/convertWhereCondition.test.ts @@ -0,0 +1,173 @@ +import { convertWhereCondition } from "~/utils/convertWhereCondition"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; + +describe("it should properly convert where condition from locking mechanism to standard cms where condition", () => { + it("should return undefined if no where condition is provided", () => { + const result = convertWhereCondition(undefined); + + expect(result).toBeUndefined(); + }); + + it("should not change where condition", async () => { + const where = { + entryId: "123", + somethingElse_gt: 123, + somethingElse_lt: 1000, + aText_contains: "webiny" + }; + const result = convertWhereCondition(where); + + expect(result).toEqual(where); + }); + + it("should convert where condition", async () => { + const constantWhere = { + somethingElse_gt: 123, + somethingElse_lt: 1000, + aText_contains: "webiny" + }; + const where = { + ...constantWhere, + id: "123", + id_in: ["123", "456"], + id_not: "123", + id_not_in: ["123", "456"] + }; + const result = convertWhereCondition(where); + + expect(result).toEqual({ + ...constantWhere, + entryId: createLockRecordDatabaseId(`123`), + entryId_in: [createLockRecordDatabaseId("123"), createLockRecordDatabaseId("456")], + entryId_not: createLockRecordDatabaseId("123"), + entryId_not_in: [createLockRecordDatabaseId("123"), createLockRecordDatabaseId("456")] + }); + }); + + it("should convert nested where condition", async () => { + const where = { + id_in: ["123", "456"], + AND: [ + { + id: "123", + id_not: "456", + somethingElse_gt: 123 + }, + { + id_in: ["789", "101112"], + OR: [ + { + id: "123" + }, + { + id: "456" + } + ] + }, + { + OR: [ + { + id: "123" + }, + { + id: "456" + }, + { + AND: [ + { + id: "789" + }, + { + id: "101112" + } + ] + } + ] + } + ], + OR: [ + { + id: "123" + }, + { + id: "456" + }, + { + AND: [ + { + id: "789" + }, + { + id: "101112" + } + ] + } + ] + }; + + const result = convertWhereCondition(where); + + expect(result).toEqual({ + entryId_in: [createLockRecordDatabaseId("123"), createLockRecordDatabaseId("456")], + AND: [ + { + entryId: createLockRecordDatabaseId("123"), + entryId_not: createLockRecordDatabaseId("456"), + somethingElse_gt: 123 + }, + { + entryId_in: [ + createLockRecordDatabaseId("789"), + createLockRecordDatabaseId("101112") + ], + OR: [ + { + entryId: createLockRecordDatabaseId("123") + }, + { + entryId: createLockRecordDatabaseId("456") + } + ] + }, + { + OR: [ + { + entryId: createLockRecordDatabaseId("123") + }, + { + entryId: createLockRecordDatabaseId("456") + }, + { + AND: [ + { + entryId: createLockRecordDatabaseId("789") + }, + { + entryId: createLockRecordDatabaseId("101112") + } + ] + } + ] + } + ], + OR: [ + { + entryId: createLockRecordDatabaseId("123") + }, + { + entryId: createLockRecordDatabaseId("456") + }, + { + AND: [ + { + entryId: createLockRecordDatabaseId("789") + }, + { + entryId: createLockRecordDatabaseId("101112") + } + ] + } + ] + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/utils/validateSameIdentity.test.ts b/packages/api-locking-mechanism/__tests__/utils/validateSameIdentity.test.ts new file mode 100644 index 00000000000..ca96bbe23c4 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/utils/validateSameIdentity.test.ts @@ -0,0 +1,48 @@ +import { WebinyError } from "@webiny/error"; +import { validateSameIdentity } from "~/utils/validateSameIdentity"; +import { createIdentity } from "~tests/helpers/identity"; + +describe("validate same identity", () => { + it("should throw an error on not matching identity", async () => { + expect.assertions(1); + + try { + validateSameIdentity({ + target: createIdentity(), + getIdentity: () => { + return createIdentity({ + id: "anotherId", + displayName: "some name", + type: "admin" + }); + } + }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError({ + message: "Cannot update lock record. Record is locked by another user.", + code: "LOCK_UPDATE_ERROR" + }) + ); + } + }); + + it("should not throw an error on matching identity", async () => { + expect.assertions(0); + try { + validateSameIdentity({ + target: createIdentity(), + getIdentity: () => { + return createIdentity(); + } + }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError({ + message: "Cannot update lock record. Record is locked by another user.", + code: "LOCK_UPDATE_ERROR" + }) + ); + } + }); +}); diff --git a/packages/api-locking-mechanism/jest.setup.js b/packages/api-locking-mechanism/jest.setup.js new file mode 100644 index 00000000000..049095053ae --- /dev/null +++ b/packages/api-locking-mechanism/jest.setup.js @@ -0,0 +1,11 @@ +const base = require("../../jest.config.base"); +const presets = require("@webiny/project-utils/testing/presets")( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-i18n", "storage-operations"], + ["@webiny/api-security", "storage-operations"], + ["@webiny/api-tenancy", "storage-operations"] +); + +module.exports = { + ...base({ path: __dirname }, presets) +}; diff --git a/packages/api-locking-mechanism/package.json b/packages/api-locking-mechanism/package.json new file mode 100644 index 00000000000..4b5a83b87f7 --- /dev/null +++ b/packages/api-locking-mechanism/package.json @@ -0,0 +1,64 @@ +{ + "name": "@webiny/api-locking-mechanism", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Locking Mechanism built on top of the Headless CMS.", + "contributors": [ + "Bruno Zorić " + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.0", + "@types/aws-lambda": "^8.10.131", + "@webiny/api": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/api-websockets": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/handler": "0.0.0", + "@webiny/handler-aws": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/pubsub": "0.0.0", + "@webiny/utils": "0.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.23.9", + "@babel/core": "^7.24.0", + "@babel/preset-env": "^7.24.0", + "@babel/preset-typescript": "^7.23.3", + "@webiny/api-i18n": "0.0.0", + "@webiny/api-security": "0.0.0", + "@webiny/api-tenancy": "0.0.0", + "@webiny/api-wcp": "0.0.0", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "graphql": "^15.8.0", + "rimraf": "^5.0.5", + "ttypescript": "^1.5.13", + "type-fest": "^2.19.0", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f", + "adio": { + "ignore": { + "src": [ + "aws-lambda" + ], + "dependencies": [ + "@types/aws-lambda" + ] + } + } +} diff --git a/packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts new file mode 100644 index 00000000000..678765cbbe7 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts @@ -0,0 +1,11 @@ +import { ILockingMechanismGetLockRecordParams, ILockingMechanismLockRecord } from "~/types"; + +export type IGetLockRecordUseCaseExecuteParams = ILockingMechanismGetLockRecordParams; + +export interface IGetLockRecordUseCaseExecute { + (params: IGetLockRecordUseCaseExecuteParams): Promise; +} + +export interface IGetLockRecordUseCase { + execute: IGetLockRecordUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IGetLockedEntryLockRecordUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IGetLockedEntryLockRecordUseCase.ts new file mode 100644 index 00000000000..7b15d8e4540 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IGetLockedEntryLockRecordUseCase.ts @@ -0,0 +1,13 @@ +import { ILockingMechanismIsLockedParams, ILockingMechanismLockRecord } from "~/types"; + +export type IGetLockedEntryLockRecordUseCaseExecuteParams = ILockingMechanismIsLockedParams; + +export interface IGetLockedEntryLockRecordUseCaseExecute { + ( + params: IGetLockedEntryLockRecordUseCaseExecuteParams + ): Promise; +} + +export interface IGetLockedEntryLockRecordUseCase { + execute: IGetLockedEntryLockRecordUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IIsEntryLocked.ts b/packages/api-locking-mechanism/src/abstractions/IIsEntryLocked.ts new file mode 100644 index 00000000000..70626981c3b --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IIsEntryLocked.ts @@ -0,0 +1,11 @@ +import { ILockingMechanismIsLockedParams } from "~/types"; + +export type IIsEntryLockedUseCaseExecuteParams = ILockingMechanismIsLockedParams; + +export interface IIsEntryLockedUseCaseExecute { + (params: IIsEntryLockedUseCaseExecuteParams): Promise; +} + +export interface IIsEntryLockedUseCase { + execute: IIsEntryLockedUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IKickOutCurrentUserUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IKickOutCurrentUserUseCase.ts new file mode 100644 index 00000000000..4aad3f9f7ef --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IKickOutCurrentUserUseCase.ts @@ -0,0 +1,7 @@ +import { ILockingMechanismLockRecord } from "~/types"; + +export type IKickOutCurrentUserUseCaseExecuteParams = ILockingMechanismLockRecord; + +export interface IKickOutCurrentUserUseCase { + execute(params: IKickOutCurrentUserUseCaseExecuteParams): Promise; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IListAllLockRecordsUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IListAllLockRecordsUseCase.ts new file mode 100644 index 00000000000..703f73c1745 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IListAllLockRecordsUseCase.ts @@ -0,0 +1,18 @@ +import { + ILockingMechanismListAllLockRecordsParams, + ILockingMechanismListAllLockRecordsResponse +} from "~/types"; + +export type IListAllLockRecordsUseCaseExecuteParams = ILockingMechanismListAllLockRecordsParams; + +export type IListAllLockRecordsUseCaseExecuteResponse = ILockingMechanismListAllLockRecordsResponse; + +export interface IListAllLockRecordsUseCaseExecute { + ( + params: IListAllLockRecordsUseCaseExecuteParams + ): Promise; +} + +export interface IListAllLockRecordsUseCase { + execute: IListAllLockRecordsUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts new file mode 100644 index 00000000000..a0a23bb3047 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts @@ -0,0 +1,14 @@ +import { IListAllLockRecordsUseCaseExecuteParams } from "./IListAllLockRecordsUseCase"; +import { ILockingMechanismListAllLockRecordsResponse } from "~/types"; + +export type IListLockRecordsUseCaseExecuteParams = IListAllLockRecordsUseCaseExecuteParams; + +export type IListLockRecordsUseCaseExecuteResponse = ILockingMechanismListAllLockRecordsResponse; + +export interface IListLockRecordsUseCaseExecute { + (params: IListLockRecordsUseCaseExecuteParams): Promise; +} + +export interface IListLockRecordsUseCase { + execute: IListLockRecordsUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts b/packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts new file mode 100644 index 00000000000..d0035f9e174 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts @@ -0,0 +1,14 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface ILockEntryUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockEntryUseCaseExecute { + (params: ILockEntryUseCaseExecuteParams): Promise; +} + +export interface ILockEntryUseCase { + execute: ILockEntryUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts new file mode 100644 index 00000000000..983fc0b67d9 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts @@ -0,0 +1,14 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface IUnlockEntryRequestUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface IUnlockEntryRequestUseCaseExecute { + (params: IUnlockEntryRequestUseCaseExecuteParams): Promise; +} + +export interface IUnlockEntryRequestUseCase { + execute: IUnlockEntryRequestUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts new file mode 100644 index 00000000000..9d4511147af --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts @@ -0,0 +1,15 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface IUnlockEntryUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + force?: boolean; +} + +export interface IUnlockEntryUseCaseExecute { + (params: IUnlockEntryUseCaseExecuteParams): Promise; +} + +export interface IUnlockEntryUseCase { + execute: IUnlockEntryUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IUpdateEntryLockUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IUpdateEntryLockUseCase.ts new file mode 100644 index 00000000000..3cea731f36b --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IUpdateEntryLockUseCase.ts @@ -0,0 +1,14 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface IUpdateEntryLockUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface IUpdateEntryLockUseCaseExecute { + (params: IUpdateEntryLockUseCaseExecuteParams): Promise; +} + +export interface IUpdateEntryLockUseCase { + execute: IUpdateEntryLockUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/crud/crud.ts b/packages/api-locking-mechanism/src/crud/crud.ts new file mode 100644 index 00000000000..4e38f6c550d --- /dev/null +++ b/packages/api-locking-mechanism/src/crud/crud.ts @@ -0,0 +1,249 @@ +import { WebinyError } from "@webiny/error"; +import { + Context, + IGetIdentity, + IGetWebsocketsContextCallable, + IHasFullAccessCallable, + ILockingMechanism, + ILockingMechanismLockRecordValues, + ILockingMechanismModelManager, + OnEntryAfterLockTopicParams, + OnEntryAfterUnlockRequestTopicParams, + OnEntryAfterUnlockTopicParams, + OnEntryBeforeLockTopicParams, + OnEntryBeforeUnlockRequestTopicParams, + OnEntryBeforeUnlockTopicParams, + OnEntryLockErrorTopicParams, + OnEntryUnlockErrorTopicParams, + OnEntryUnlockRequestErrorTopicParams +} from "~/types"; +import { RECORD_LOCKING_MODEL_ID } from "./model"; +import { IGetLockRecordUseCaseExecute } from "~/abstractions/IGetLockRecordUseCase"; +import { IIsEntryLockedUseCaseExecute } from "~/abstractions/IIsEntryLocked"; +import { ILockEntryUseCaseExecute } from "~/abstractions/ILockEntryUseCase"; +import { IUnlockEntryUseCaseExecute } from "~/abstractions/IUnlockEntryUseCase"; +import { createUseCases } from "~/useCases"; +import { IUnlockEntryRequestUseCaseExecute } from "~/abstractions/IUnlockEntryRequestUseCase"; +import { createTopic } from "@webiny/pubsub"; +import { IListAllLockRecordsUseCaseExecute } from "~/abstractions/IListAllLockRecordsUseCase"; +import { IListLockRecordsUseCaseExecute } from "~/abstractions/IListLockRecordsUseCase"; +import { IUpdateEntryLockUseCaseExecute } from "~/abstractions/IUpdateEntryLockUseCase"; +import { IGetLockedEntryLockRecordUseCaseExecute } from "~/abstractions/IGetLockedEntryLockRecordUseCase"; + +interface Params { + context: Pick; +} + +export const createLockingMechanismCrud = async ({ + context +}: Params): Promise => { + const getModel = async () => { + const model = await context.cms.getModel(RECORD_LOCKING_MODEL_ID); + if (model) { + return model; + } + throw new WebinyError("Locking Mechanism model not found", "MODEL_NOT_FOUND", { + modelId: RECORD_LOCKING_MODEL_ID + }); + }; + + const getManager = async (): Promise => { + return await context.cms.getEntryManager( + RECORD_LOCKING_MODEL_ID + ); + }; + + const getIdentity: IGetIdentity = () => { + const identity = context.security.getIdentity(); + if (!identity) { + throw new WebinyError("Identity missing."); + } + return { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }; + }; + + const hasFullAccess: IHasFullAccessCallable = async () => { + return await context.security.hasFullAccess(); + }; + + const onEntryBeforeLock = createTopic( + "cms.lockingMechanism.onEntryBeforeLock" + ); + const onEntryAfterLock = createTopic( + "cms.lockingMechanism.onEntryAfterLock" + ); + const onEntryLockError = createTopic( + "cms.lockingMechanism.onEntryLockError" + ); + + const onEntryBeforeUnlock = createTopic( + "cms.lockingMechanism.onEntryBeforeUnlock" + ); + const onEntryAfterUnlock = createTopic( + "cms.lockingMechanism.onEntryAfterUnlock" + ); + const onEntryUnlockError = createTopic( + "cms.lockingMechanism.onEntryUnlockError" + ); + + const onEntryBeforeUnlockRequest = createTopic( + "cms.lockingMechanism.onEntryBeforeUnlockRequest" + ); + const onEntryAfterUnlockRequest = createTopic( + "cms.lockingMechanism.onEntryAfterUnlockRequest" + ); + const onEntryUnlockRequestError = createTopic( + "cms.lockingMechanism.onEntryUnlockRequestError" + ); + + const getWebsockets: IGetWebsocketsContextCallable = () => { + return context.websockets; + }; + + const { + listLockRecordsUseCase, + listAllLockRecordsUseCase, + getLockRecordUseCase, + isEntryLockedUseCase, + getLockedEntryLockRecordUseCase, + lockEntryUseCase, + updateEntryLockUseCase, + unlockEntryUseCase, + unlockEntryRequestUseCase + } = createUseCases({ + getIdentity, + getManager, + hasFullAccess, + getWebsockets + }); + + const listAllLockRecords: IListAllLockRecordsUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.listAllLockRecords", async () => { + return listAllLockRecordsUseCase.execute(params); + }); + }; + + const listLockRecords: IListLockRecordsUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.listLockRecords", async () => { + return listLockRecordsUseCase.execute(params); + }); + }; + + const getLockRecord: IGetLockRecordUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.getLockRecord", async () => { + return getLockRecordUseCase.execute(params); + }); + }; + + const isEntryLocked: IIsEntryLockedUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.isEntryLocked", async () => { + return isEntryLockedUseCase.execute(params); + }); + }; + + const getLockedEntryLockRecord: IGetLockedEntryLockRecordUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.getLockedEntryLockRecord", async () => { + return getLockedEntryLockRecordUseCase.execute(params); + }); + }; + + const lockEntry: ILockEntryUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.lockEntry", async () => { + try { + await onEntryBeforeLock.publish(params); + const record = await lockEntryUseCase.execute(params); + await onEntryAfterLock.publish({ + ...params, + record + }); + return record; + } catch (ex) { + await onEntryLockError.publish({ + ...params, + error: ex + }); + throw ex; + } + }); + }; + + const updateEntryLock: IUpdateEntryLockUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.updateEntryLock", async () => { + return updateEntryLockUseCase.execute(params); + }); + }; + + const unlockEntry: IUnlockEntryUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.unlockEntry", async () => { + try { + await onEntryBeforeUnlock.publish({ + ...params, + getIdentity + }); + const record = await unlockEntryUseCase.execute(params); + await onEntryAfterUnlock.publish({ + ...params, + record + }); + return record; + } catch (ex) { + await onEntryUnlockError.publish({ + ...params, + error: ex + }); + throw ex; + } + }); + }; + + const unlockEntryRequest: IUnlockEntryRequestUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.unlockEntryRequest", async () => { + try { + await onEntryBeforeUnlockRequest.publish(params); + const record = await unlockEntryRequestUseCase.execute(params); + await onEntryAfterUnlockRequest.publish({ + ...params, + record + }); + return record; + } catch (ex) { + await onEntryUnlockRequestError.publish({ + ...params, + error: ex + }); + throw ex; + } + }); + }; + + return { + /** + * Lifecycle events + */ + onEntryBeforeLock, + onEntryAfterLock, + onEntryLockError, + onEntryBeforeUnlock, + onEntryAfterUnlock, + onEntryUnlockError, + onEntryBeforeUnlockRequest, + onEntryAfterUnlockRequest, + onEntryUnlockRequestError, + /** + * Methods + */ + getModel, + listLockRecords, + listAllLockRecords, + getLockRecord, + isEntryLocked, + getLockedEntryLockRecord, + lockEntry, + updateEntryLock, + unlockEntry, + unlockEntryRequest + }; +}; diff --git a/packages/api-locking-mechanism/src/crud/model.ts b/packages/api-locking-mechanism/src/crud/model.ts new file mode 100644 index 00000000000..0e58ade37e3 --- /dev/null +++ b/packages/api-locking-mechanism/src/crud/model.ts @@ -0,0 +1,150 @@ +import { createCmsModel, createPrivateModel } from "@webiny/api-headless-cms"; + +export const RECORD_LOCKING_MODEL_ID = "wby_recordLocking"; + +export const createLockingModel = () => { + return createCmsModel( + createPrivateModel({ + modelId: RECORD_LOCKING_MODEL_ID, + name: "Record Lock Tracking", + fields: [ + { + id: "targetId", + type: "text", + fieldId: "targetId", + storageId: "text@targetId", + label: "Target ID", + validation: [ + { + name: "required", + message: "Target ID is required." + } + ] + }, + /** + * Since we need a generic way to track records, we will use type to determine if it's a cms record or a page or a form, etc... + * Update IHeadlessCmsLockRecordValues in types.ts file with additional fields as required. + * + * @see IHeadlessCmsLockRecordValues + */ + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Record Type", + validation: [ + { + name: "required", + message: "Record type is required." + } + ] + }, + { + id: "actions", + type: "object", + fieldId: "actions", + storageId: "object@actions", + label: "Actions", + multipleValues: true, + settings: { + fields: [ + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Action Type", + validation: [ + { + name: "required", + message: "Action type is required." + } + ] + }, + { + id: "message", + type: "text", + fieldId: "message", + storageId: "text@message", + label: "Message" + }, + { + id: "createdBy", + type: "object", + fieldId: "createdBy", + storageId: "object@createdBy", + label: "Created By", + validation: [ + { + name: "required", + message: "Created by is required." + } + ], + settings: { + fields: [ + { + id: "id", + type: "text", + fieldId: "id", + storageId: "text@id", + label: "ID", + validation: [ + { + name: "required", + message: "ID is required." + } + ] + }, + { + id: "displayName", + type: "text", + fieldId: "displayName", + storageId: "text@displayName", + label: "Display Name", + validation: [ + { + name: "required", + message: "Display name is required." + } + ] + }, + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Type", + validation: [ + { + name: "required", + message: "Type is required." + } + ] + } + ] + } + }, + { + id: "createdOn", + type: "datetime", + fieldId: "createdOn", + storageId: "datetime@createdOn", + settings: { + type: "dateTimeWithoutTimezone" + }, + label: "Created On", + validation: [ + { + name: "required", + message: "Created on is required." + } + ] + } + ] + } + } + ] + }) + ); +}; diff --git a/packages/api-locking-mechanism/src/graphql/schema.ts b/packages/api-locking-mechanism/src/graphql/schema.ts new file mode 100644 index 00000000000..789dab4a50f --- /dev/null +++ b/packages/api-locking-mechanism/src/graphql/schema.ts @@ -0,0 +1,292 @@ +import { resolve, resolveList } from "~/utils/resolve"; +import { Context } from "~/types"; +import { + createGraphQLSchemaPlugin, + IGraphQLSchemaPlugin, + NotFoundError +} from "@webiny/handler-graphql"; +import { renderFields } from "@webiny/api-headless-cms/utils/renderFields"; +import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords"; +import { renderListFilterFields } from "@webiny/api-headless-cms/utils/renderListFilterFields"; +import { renderSortEnum } from "@webiny/api-headless-cms/utils/renderSortEnum"; +import { checkPermissions } from "~/utils/checkPermissions"; + +interface Params { + context: Pick; +} +export const createGraphQLSchema = async ( + params: Params +): Promise> => { + const context = params.context; + + const model = await context.lockingMechanism.getModel(); + + const models = await context.security.withoutAuthorization(async () => { + return (await context.cms.listModels()).filter(model => { + if (model.fields.length === 0) { + return false; + } else if (model.isPrivate) { + return false; + } + return true; + }); + }); + + const fieldTypePlugins = createFieldTypePluginRecords(context.plugins); + + const lockingMechanismFields = renderFields({ + models, + model, + fields: model.fields, + type: "manage", + fieldTypePlugins + }); + + const listFilterFieldsRender = renderListFilterFields({ + model, + fields: model.fields, + type: "manage", + fieldTypePlugins, + excludeFields: ["entryId"] + }); + + const sortEnumRender = renderSortEnum({ + model, + fields: model.fields, + fieldTypePlugins, + sorterPlugins: [] + }); + + const plugin = createGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + ${lockingMechanismFields.map(f => f.typeDefs).join("\n")} + + type LockingMechanismError { + message: String + code: String + data: JSON + stack: String + } + + enum LockingMechanismRecordActionType { + requested + approved + denied + } + + type LockingMechanismIdentity { + id: String! + displayName: String + type: String + } + + type LockingMechanismRecordAction { + id: ID! + type: LockingMechanismRecordActionType! + message: String + createdBy: LockingMechanismIdentity! + createdOn: DateTime! + } + + type LockingMechanismRecord { + id: ID! + lockedBy: LockingMechanismIdentity! + lockedOn: DateTime! + updatedOn: DateTime! + expiresOn: DateTime! + ${lockingMechanismFields.map(f => f.fields).join("\n")} + } + + type LockingMechanismIsEntryLockedResponse { + data: Boolean + error: LockingMechanismError + } + + type LockingMechanismGetLockRecordResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismGetLockedEntryLockRecordResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismListLockRecordsResponse { + data: [LockingMechanismRecord!] + error: LockingMechanismError + } + + type LockingMechanismLockEntryResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismUpdateLockResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismUnlockEntryResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismUnlockEntryRequestResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + input LockingMechanismListWhereInput { + ${listFilterFieldsRender} + } + + enum LockingMechanismListSorter { + ${sortEnumRender} + } + + type LockingMechanismQuery { + _empty: String + } + + type LockingMechanismMutation { + _empty: String + } + + extend type LockingMechanismQuery { + isEntryLocked(id: ID!, type: String!): LockingMechanismIsEntryLockedResponse! + getLockRecord(id: ID!, type: String!): LockingMechanismGetLockRecordResponse! + # Returns lock record or null - if entry is locked in context of the current user, does not throw an error like getLockRecord if no record in the DB + getLockedEntryLockRecord(id: ID!, type: String!): LockingMechanismGetLockedEntryLockRecordResponse! + listAllLockRecords( + where: LockingMechanismListWhereInput + sort: [LockingMechanismListSorter!] + limit: Int + after: String + ): LockingMechanismListLockRecordsResponse! + # Basically same as listAllLockRecords except this one will filter out records with expired lock. + listLockRecords( + where: LockingMechanismListWhereInput + sort: [LockingMechanismListSorter!] + limit: Int + after: String + ): LockingMechanismListLockRecordsResponse! + } + + extend type LockingMechanismMutation { + lockEntry(id: ID!, type: String!): LockingMechanismLockEntryResponse! + updateEntryLock(id: ID!, type: String!): LockingMechanismUpdateLockResponse! + unlockEntry(id: ID!, type: String!, force: Boolean): LockingMechanismUnlockEntryResponse! + unlockEntryRequest( + id: ID! + type: String! + ): LockingMechanismUnlockEntryRequestResponse! + } + + extend type Query { + lockingMechanism: LockingMechanismQuery + } + + extend type Mutation { + lockingMechanism: LockingMechanismMutation + } + `, + resolvers: { + Query: { + lockingMechanism: async () => ({}) + }, + Mutation: { + lockingMechanism: async () => ({}) + }, + LockingMechanismQuery: { + async isEntryLocked(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + return context.lockingMechanism.isEntryLocked({ + id: args.id, + type: args.type + }); + }); + }, + async getLockRecord(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + const result = await context.lockingMechanism.getLockRecord({ + id: args.id, + type: args.type + }); + if (result) { + return result; + } + throw new NotFoundError("Lock record not found."); + }); + }, + async getLockedEntryLockRecord(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + return await context.lockingMechanism.getLockedEntryLockRecord({ + id: args.id, + type: args.type + }); + }); + }, + + async listLockRecords(_, args, context) { + return resolveList(async () => { + await checkPermissions(context); + return await context.lockingMechanism.listLockRecords(args); + }); + }, + listAllLockRecords(_, args, context) { + return resolveList(async () => { + await checkPermissions(context); + return await context.lockingMechanism.listAllLockRecords(args); + }); + } + }, + LockingMechanismMutation: { + async lockEntry(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + return context.lockingMechanism.lockEntry({ + id: args.id, + type: args.type + }); + }); + }, + async updateEntryLock(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + return context.lockingMechanism.updateEntryLock({ + id: args.id, + type: args.type + }); + }); + }, + async unlockEntry(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + return await context.lockingMechanism.unlockEntry({ + id: args.id, + type: args.type, + force: args.force + }); + }); + }, + async unlockEntryRequest(_, args, context) { + return resolve(async () => { + await checkPermissions(context); + return await context.lockingMechanism.unlockEntryRequest({ + id: args.id, + type: args.type + }); + }); + } + } + } + }); + + plugin.name = "lockingMechanism.graphql.schema.locking"; + + return plugin; +}; diff --git a/packages/api-locking-mechanism/src/index.ts b/packages/api-locking-mechanism/src/index.ts new file mode 100644 index 00000000000..5f631526547 --- /dev/null +++ b/packages/api-locking-mechanism/src/index.ts @@ -0,0 +1,34 @@ +import { createGraphQLSchema } from "~/graphql/schema"; +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~/types"; +import { createLockingMechanismCrud } from "~/crud/crud"; +import { createLockingModel } from "~/crud/model"; +import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; + +const createContextPlugin = () => { + const plugin = new ContextPlugin(async context => { + if (!context.wcp.canUseRecordLocking()) { + return; + } + + const ready = await isHeadlessCmsReady(context); + if (!ready) { + return; + } + context.plugins.register(createLockingModel()); + + context.lockingMechanism = await createLockingMechanismCrud({ + context + }); + + const graphQlPlugin = await createGraphQLSchema({ context }); + context.plugins.register(graphQlPlugin); + }); + plugin.name = "context.lockingMechanism"; + + return plugin; +}; + +export const createLockingMechanism = () => { + return [createContextPlugin()]; +}; diff --git a/packages/api-locking-mechanism/src/types.ts b/packages/api-locking-mechanism/src/types.ts new file mode 100644 index 00000000000..6ff2171b83a --- /dev/null +++ b/packages/api-locking-mechanism/src/types.ts @@ -0,0 +1,239 @@ +import { + CmsContext, + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsError, + CmsIdentity, + CmsModel, + CmsModelManager +} from "@webiny/api-headless-cms/types"; +import { Topic } from "@webiny/pubsub/types"; +import { + Context as IWebsocketsContext, + IWebsocketsContextObject +} from "@webiny/api-websockets/types"; + +export { CmsError, CmsEntry }; + +export type ILockingMechanismIdentity = CmsIdentity; + +export type ILockingMechanismModelManager = CmsModelManager; + +export type ILockingMechanismMeta = CmsEntryMeta; + +export interface IHasFullAccessCallable { + (): Promise; +} + +export interface IGetWebsocketsContextCallable { + (): IWebsocketsContextObject; +} + +export interface IGetIdentity { + (): ILockingMechanismIdentity; +} + +export interface ILockingMechanismLockRecordValues { + targetId: string; + type: ILockingMechanismLockRecordEntryType; + actions?: ILockingMechanismLockRecordAction[]; +} +export enum ILockingMechanismLockRecordActionType { + requested = "requested", + approved = "approved", + denied = "denied" +} + +export interface ILockingMechanismLockRecordRequestedAction { + type: ILockingMechanismLockRecordActionType.requested; + message?: string; + createdOn: Date; + createdBy: ILockingMechanismIdentity; +} + +export interface ILockingMechanismLockRecordApprovedAction { + type: ILockingMechanismLockRecordActionType.approved; + message?: string; + createdOn: Date; + createdBy: ILockingMechanismIdentity; +} + +export interface ILockingMechanismLockRecordDeniedAction { + type: ILockingMechanismLockRecordActionType.denied; + message?: string; + createdOn: Date; + createdBy: ILockingMechanismIdentity; +} + +export type ILockingMechanismLockRecordAction = + | ILockingMechanismLockRecordRequestedAction + | ILockingMechanismLockRecordApprovedAction + | ILockingMechanismLockRecordDeniedAction; + +export interface ILockingMechanismLockRecordObject { + id: string; + targetId: string; + type: ILockingMechanismLockRecordEntryType; + lockedBy: ILockingMechanismIdentity; + lockedOn: Date; + updatedOn: Date; + expiresOn: Date; + actions?: ILockingMechanismLockRecordAction[]; +} + +export interface ILockingMechanismLockRecord extends ILockingMechanismLockRecordObject { + toObject(): ILockingMechanismLockRecordObject; + addAction(action: ILockingMechanismLockRecordAction): void; + getUnlockRequested(): ILockingMechanismLockRecordRequestedAction | undefined; + getUnlockApproved(): ILockingMechanismLockRecordApprovedAction | undefined; + getUnlockDenied(): ILockingMechanismLockRecordDeniedAction | undefined; +} + +/** + * Do not use any special chars other than #, as we use this to create lock record IDs. + */ +export type ILockingMechanismLockRecordEntryType = string; + +export type ILockingMechanismListAllLockRecordsParams = Pick< + CmsEntryListParams, + "where" | "limit" | "sort" | "after" +>; + +export type ILockingMechanismListLockRecordsParams = ILockingMechanismListAllLockRecordsParams; + +export interface ILockingMechanismListAllLockRecordsResponse { + items: ILockingMechanismLockRecord[]; + meta: ILockingMechanismMeta; +} + +export type ILockingMechanismListLockRecordsResponse = ILockingMechanismListAllLockRecordsResponse; + +export interface ILockingMechanismGetLockRecordParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismIsLockedParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismGetLockedEntryLockRecordParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismLockEntryParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismUpdateEntryLockParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismUnlockEntryParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + force?: boolean; +} + +export interface ILockingMechanismUnlockEntryRequestParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface OnEntryBeforeLockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface OnEntryAfterLockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + record: ILockingMechanismLockRecord; +} + +export interface OnEntryLockErrorTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + error: CmsError; +} + +export interface OnEntryBeforeUnlockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + getIdentity: IGetIdentity; +} + +export interface OnEntryAfterUnlockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + record: ILockingMechanismLockRecord; +} + +export interface OnEntryUnlockErrorTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + error: CmsError; +} + +export interface OnEntryBeforeUnlockRequestTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface OnEntryAfterUnlockRequestTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + record: ILockingMechanismLockRecord; +} + +export interface OnEntryUnlockRequestErrorTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + error: CmsError; +} + +export interface ILockingMechanism { + onEntryBeforeLock: Topic; + onEntryAfterLock: Topic; + onEntryLockError: Topic; + onEntryBeforeUnlock: Topic; + onEntryAfterUnlock: Topic; + onEntryUnlockError: Topic; + onEntryBeforeUnlockRequest: Topic; + onEntryAfterUnlockRequest: Topic; + onEntryUnlockRequestError: Topic; + getModel(): Promise; + listAllLockRecords( + params?: ILockingMechanismListAllLockRecordsParams + ): Promise; + /** + * Same call as listAllLockRecords, except this one will filter out records with expired lock. + */ + listLockRecords( + params?: ILockingMechanismListLockRecordsParams + ): Promise; + getLockRecord( + params: ILockingMechanismGetLockRecordParams + ): Promise; + isEntryLocked(params: ILockingMechanismIsLockedParams): Promise; + getLockedEntryLockRecord( + params: ILockingMechanismGetLockedEntryLockRecordParams + ): Promise; + lockEntry(params: ILockingMechanismLockEntryParams): Promise; + updateEntryLock( + params: ILockingMechanismUpdateEntryLockParams + ): Promise; + unlockEntry(params: ILockingMechanismUnlockEntryParams): Promise; + unlockEntryRequest( + params: ILockingMechanismUnlockEntryRequestParams + ): Promise; +} + +export interface Context extends CmsContext, IWebsocketsContext { + lockingMechanism: ILockingMechanism; +} diff --git a/packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts b/packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts new file mode 100644 index 00000000000..d73dda0e992 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts @@ -0,0 +1,41 @@ +import { + IGetLockRecordUseCase, + IGetLockRecordUseCaseExecuteParams +} from "~/abstractions/IGetLockRecordUseCase"; +import { ILockingMechanismModelManager, ILockingMechanismLockRecord } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { createIdentifier } from "@webiny/utils"; + +export interface IGetLockRecordUseCaseParams { + getManager(): Promise; +} + +export class GetLockRecordUseCase implements IGetLockRecordUseCase { + private readonly getManager: IGetLockRecordUseCaseParams["getManager"]; + + public constructor(params: IGetLockRecordUseCaseParams) { + this.getManager = params.getManager; + } + + public async execute( + input: IGetLockRecordUseCaseExecuteParams + ): Promise { + const recordId = createLockRecordDatabaseId(input.id); + const id = createIdentifier({ + id: recordId, + version: 1 + }); + try { + const manager = await this.getManager(); + const result = await manager.get(id); + return convertEntryToLockRecord(result); + } catch (ex) { + if (ex instanceof NotFoundError) { + return null; + } + throw ex; + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts b/packages/api-locking-mechanism/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts new file mode 100644 index 00000000000..6f33de4db33 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts @@ -0,0 +1,39 @@ +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { IGetIdentity, ILockingMechanismLockRecord } from "~/types"; +import { + IGetLockedEntryLockRecordUseCase, + IGetLockedEntryLockRecordUseCaseExecuteParams +} from "~/abstractions/IGetLockedEntryLockRecordUseCase"; +import { IIsLocked } from "~/utils/isLockedFactory"; + +export interface IGetLockedEntryLockRecordUseCaseParams { + getLockRecordUseCase: IGetLockRecordUseCase; + isLocked: IIsLocked; + getIdentity: IGetIdentity; +} + +export class GetLockedEntryLockRecordUseCase implements IGetLockedEntryLockRecordUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly isLocked: IIsLocked; + private readonly getIdentity: IGetIdentity; + + public constructor(params: IGetLockedEntryLockRecordUseCaseParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.isLocked = params.isLocked; + this.getIdentity = params.getIdentity; + } + + public async execute( + params: IGetLockedEntryLockRecordUseCaseExecuteParams + ): Promise { + const result = await this.getLockRecordUseCase.execute(params); + if (!result?.lockedBy) { + return null; + } + const identity = this.getIdentity(); + if (identity.id === result.lockedBy.id) { + return null; + } + return this.isLocked(result) ? result : null; + } +} diff --git a/packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts new file mode 100644 index 00000000000..be2563be9ff --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts @@ -0,0 +1,46 @@ +import { + IIsEntryLockedUseCase, + IIsEntryLockedUseCaseExecuteParams +} from "~/abstractions/IIsEntryLocked"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { IIsLocked } from "~/utils/isLockedFactory"; +import { IGetIdentity } from "~/types"; + +export interface IIsEntryLockedParams { + getLockRecordUseCase: IGetLockRecordUseCase; + isLocked: IIsLocked; + getIdentity: IGetIdentity; +} + +export class IsEntryLockedUseCase implements IIsEntryLockedUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly isLocked: IIsLocked; + private readonly getIdentity: IGetIdentity; + + public constructor(params: IIsEntryLockedParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.isLocked = params.isLocked; + this.getIdentity = params.getIdentity; + } + + public async execute(params: IIsEntryLockedUseCaseExecuteParams): Promise { + try { + const result = await this.getLockRecordUseCase.execute(params); + if (!result) { + return false; + } + const identity = this.getIdentity(); + if (result.lockedBy.id === identity.id) { + return false; + } + + return this.isLocked(result); + } catch (ex) { + if (ex instanceof NotFoundError === false) { + throw ex; + } + return false; + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-locking-mechanism/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts new file mode 100644 index 00000000000..3500bffd9e6 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts @@ -0,0 +1,53 @@ +import { + IKickOutCurrentUserUseCase, + IKickOutCurrentUserUseCaseExecuteParams +} from "~/abstractions/IKickOutCurrentUserUseCase"; +import { IGetIdentity, IGetWebsocketsContextCallable } from "~/types"; +import { parseIdentifier } from "@webiny/utils"; + +export interface IKickOutCurrentUserUseCaseParams { + getWebsockets: IGetWebsocketsContextCallable; + getIdentity: IGetIdentity; +} + +export class KickOutCurrentUserUseCase implements IKickOutCurrentUserUseCase { + private readonly getWebsockets: IGetWebsocketsContextCallable; + private readonly getIdentity: IGetIdentity; + + public constructor(params: IKickOutCurrentUserUseCaseParams) { + this.getWebsockets = params.getWebsockets; + this.getIdentity = params.getIdentity; + } + + public async execute(record: IKickOutCurrentUserUseCaseExecuteParams): Promise { + const { lockedBy, id } = record; + + const websockets = this.getWebsockets(); + + const { id: entryId } = parseIdentifier(id); + + const identity = this.getIdentity(); + + /** + * We do not want any errors to leak out of this method. + * Just log the error, if any. + */ + try { + await websockets.send( + { id: lockedBy.id }, + { + action: `lockingMechanism.entry.kickOut.${entryId}`, + data: { + record: record.toObject(), + user: identity + } + } + ); + } catch (ex) { + console.error( + `Could not send the kickOut message to a user with identity id: ${lockedBy.id}. More info in next log line.` + ); + console.info(ex); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts b/packages/api-locking-mechanism/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts new file mode 100644 index 00000000000..c0566d4ade9 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts @@ -0,0 +1,38 @@ +import { + IListAllLockRecordsUseCase, + IListAllLockRecordsUseCaseExecuteParams, + IListAllLockRecordsUseCaseExecuteResponse +} from "~/abstractions/IListAllLockRecordsUseCase"; +import { ILockingMechanismModelManager } from "~/types"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { convertWhereCondition } from "~/utils/convertWhereCondition"; + +export interface IListAllLockRecordsUseCaseParams { + getManager(): Promise; +} + +export class ListAllLockRecordsUseCase implements IListAllLockRecordsUseCase { + private readonly getManager: () => Promise; + public constructor(params: IListAllLockRecordsUseCaseParams) { + this.getManager = params.getManager; + } + public async execute( + input: IListAllLockRecordsUseCaseExecuteParams + ): Promise { + try { + const manager = await this.getManager(); + const params: IListAllLockRecordsUseCaseExecuteParams = { + ...input, + where: convertWhereCondition(input.where) + }; + + const [items, meta] = await manager.listLatest(params); + return { + items: items.map(convertEntryToLockRecord), + meta + }; + } catch (ex) { + throw ex; + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts b/packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts new file mode 100644 index 00000000000..72ff2df28bd --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts @@ -0,0 +1,37 @@ +import { + IListLockRecordsUseCase, + IListLockRecordsUseCaseExecuteParams, + IListLockRecordsUseCaseExecuteResponse +} from "~/abstractions/IListLockRecordsUseCase"; +import { IGetIdentity } from "~/types"; + +export interface IListLockRecordsUseCaseParams { + listAllLockRecordsUseCase: IListLockRecordsUseCase; + timeout: number; + getIdentity: IGetIdentity; +} + +export class ListLockRecordsUseCase implements IListLockRecordsUseCase { + private readonly listAllLockRecordsUseCase: IListLockRecordsUseCase; + private readonly timeout: number; + private readonly getIdentity: IGetIdentity; + + public constructor(params: IListLockRecordsUseCaseParams) { + this.listAllLockRecordsUseCase = params.listAllLockRecordsUseCase; + this.timeout = params.timeout; + this.getIdentity = params.getIdentity; + } + public async execute( + input: IListLockRecordsUseCaseExecuteParams + ): Promise { + const identity = this.getIdentity(); + return this.listAllLockRecordsUseCase.execute({ + ...input, + where: { + ...input.where, + createdBy_not: identity.id, + savedOn_gte: new Date(new Date().getTime() - this.timeout) + } + }); + } +} diff --git a/packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts b/packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts new file mode 100644 index 00000000000..9e05b0f08e6 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts @@ -0,0 +1,68 @@ +import WebinyError from "@webiny/error"; +import { + ILockEntryUseCase, + ILockEntryUseCaseExecuteParams +} from "~/abstractions/ILockEntryUseCase"; +import { + ILockingMechanismLockRecord, + ILockingMechanismLockRecordValues, + ILockingMechanismModelManager +} from "~/types"; +import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { NotFoundError } from "@webiny/handler-graphql"; + +export interface ILockEntryUseCaseParams { + isEntryLockedUseCase: IIsEntryLockedUseCase; + getManager(): Promise; +} + +export class LockEntryUseCase implements ILockEntryUseCase { + private readonly isEntryLockedUseCase: IIsEntryLockedUseCase; + private readonly getManager: () => Promise; + + public constructor(params: ILockEntryUseCaseParams) { + this.isEntryLockedUseCase = params.isEntryLockedUseCase; + this.getManager = params.getManager; + } + + public async execute( + params: ILockEntryUseCaseExecuteParams + ): Promise { + let locked = false; + try { + locked = await this.isEntryLockedUseCase.execute(params); + } catch (ex) { + if (ex instanceof NotFoundError === false) { + throw ex; + } + locked = false; + } + if (locked) { + throw new WebinyError("Entry is already locked for editing.", "ENTRY_ALREADY_LOCKED", { + ...params + }); + } + try { + const manager = await this.getManager(); + + const id = createLockRecordDatabaseId(params.id); + const entry = await manager.create({ + id, + targetId: params.id, + type: params.type, + actions: [] + }); + return convertEntryToLockRecord(entry); + } catch (ex) { + throw new WebinyError( + `Could not lock entry: ${ex.message}`, + ex.code || "LOCK_ENTRY_ERROR", + { + ...ex.data + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts b/packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts new file mode 100644 index 00000000000..3e87c86effa --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts @@ -0,0 +1,94 @@ +import WebinyError from "@webiny/error"; +import { + IUnlockEntryUseCase, + IUnlockEntryUseCaseExecuteParams +} from "~/abstractions/IUnlockEntryUseCase"; +import { + IGetIdentity, + IHasFullAccessCallable, + ILockingMechanismLockRecord, + ILockingMechanismModelManager +} from "~/types"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { validateSameIdentity } from "~/utils/validateSameIdentity"; +import { NotAuthorizedError } from "@webiny/api-security"; +import { IKickOutCurrentUserUseCase } from "~/abstractions/IKickOutCurrentUserUseCase"; + +export interface IUnlockEntryUseCaseParams { + readonly getLockRecordUseCase: IGetLockRecordUseCase; + readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase; + getManager(): Promise; + getIdentity: IGetIdentity; + hasFullAccess: IHasFullAccessCallable; +} + +export class UnlockEntryUseCase implements IUnlockEntryUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase; + private readonly getManager: () => Promise; + private readonly getIdentity: IGetIdentity; + private readonly hasFullAccess: IHasFullAccessCallable; + + public constructor(params: IUnlockEntryUseCaseParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.kickOutCurrentUserUseCase = params.kickOutCurrentUserUseCase; + this.getManager = params.getManager; + this.getIdentity = params.getIdentity; + this.hasFullAccess = params.hasFullAccess; + } + + public async execute( + params: IUnlockEntryUseCaseExecuteParams + ): Promise { + const record = await this.getLockRecordUseCase.execute(params); + if (!record) { + throw new WebinyError("Lock Record not found.", "LOCK_RECORD_NOT_FOUND", { + ...params + }); + } + + /** + * We need to validate that the user executing unlock is the same user that locked the entry. + * In case it is not the same user, there is a possibility that it is a user which has full access, + * and at that point, we allow unlocking, but we also need to message the user who locked the entry. + * + */ + let kickOutCurrentUser = false; + try { + validateSameIdentity({ + getIdentity: this.getIdentity, + target: record.lockedBy + }); + } catch (ex) { + if (!params.force) { + throw ex; + } + const hasFullAccess = await this.hasFullAccess(); + if (ex instanceof NotAuthorizedError === false || !hasFullAccess) { + throw ex; + } + + kickOutCurrentUser = true; + } + + try { + const manager = await this.getManager(); + await manager.delete(createLockRecordDatabaseId(params.id)); + + if (!kickOutCurrentUser) { + return record; + } + await this.kickOutCurrentUserUseCase.execute(record); + return record; + } catch (ex) { + throw new WebinyError( + `Could not unlock entry: ${ex.message}`, + ex.code || "UNLOCK_ENTRY_ERROR", + { + ...ex.data + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts b/packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts new file mode 100644 index 00000000000..c97419f6254 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts @@ -0,0 +1,103 @@ +import WebinyError from "@webiny/error"; +import { + IUnlockEntryRequestUseCase, + IUnlockEntryRequestUseCaseExecuteParams +} from "~/abstractions/IUnlockEntryRequestUseCase"; +import { + IGetIdentity, + ILockingMechanismLockRecord, + ILockingMechanismLockRecordActionType, + ILockingMechanismModelManager +} from "~/types"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { createIdentifier } from "@webiny/utils"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; + +export interface IUnlockEntryRequestUseCaseParams { + getLockRecordUseCase: IGetLockRecordUseCase; + getManager: () => Promise; + getIdentity: IGetIdentity; +} + +export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly getManager: () => Promise; + private readonly getIdentity: IGetIdentity; + + public constructor(params: IUnlockEntryRequestUseCaseParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.getManager = params.getManager; + this.getIdentity = params.getIdentity; + } + + public async execute( + params: IUnlockEntryRequestUseCaseExecuteParams + ): Promise { + const record = await this.getLockRecordUseCase.execute(params); + if (!record) { + throw new WebinyError("Entry is not locked.", "ENTRY_NOT_LOCKED", { + ...params + }); + } + const unlockRequested = record.getUnlockRequested(); + if (unlockRequested) { + const currentIdentity = this.getIdentity(); + /** + * If a current identity did not request unlock, we will not allow that user to continue. + */ + if (unlockRequested.createdBy.id !== currentIdentity.id) { + throw new WebinyError( + "Unlock request already sent.", + "UNLOCK_REQUEST_ALREADY_SENT", + { + ...params, + identity: unlockRequested.createdBy + } + ); + } + const approved = record.getUnlockApproved(); + const denied = record.getUnlockDenied(); + if (approved || denied) { + return record; + } + throw new WebinyError("Unlock request already sent.", "UNLOCK_REQUEST_ALREADY_SENT", { + ...params, + identity: unlockRequested.createdBy + }); + } + + record.addAction({ + type: ILockingMechanismLockRecordActionType.requested, + createdOn: new Date(), + createdBy: this.getIdentity() + }); + + try { + const manager = await this.getManager(); + + const entryId = createLockRecordDatabaseId(record.id); + const id = createIdentifier({ + id: entryId, + version: 1 + }); + const result = await manager.update(id, record.toObject()); + return convertEntryToLockRecord(result); + } catch (ex) { + throw new WebinyError( + "Could not update record with a unlock request.", + "UNLOCK_REQUEST_ERROR", + { + ...ex.data, + error: { + message: ex.message, + code: ex.code + }, + id: params.id, + type: params.type, + recordId: record.id + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-locking-mechanism/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts new file mode 100644 index 00000000000..9f1fa4cdaa4 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts @@ -0,0 +1,69 @@ +import { + IUpdateEntryLockUseCase, + IUpdateEntryLockUseCaseExecuteParams +} from "~/abstractions/IUpdateEntryLockUseCase"; +import { IGetIdentity, ILockingMechanismLockRecord, ILockingMechanismModelManager } from "~/types"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { WebinyError } from "@webiny/error"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { createIdentifier } from "@webiny/utils"; +import { validateSameIdentity } from "~/utils/validateSameIdentity"; +import { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase"; + +export interface IUpdateEntryLockUseCaseParams { + readonly getLockRecordUseCase: IGetLockRecordUseCase; + readonly lockEntryUseCase: ILockEntryUseCase; + getManager(): Promise; + getIdentity: IGetIdentity; +} + +export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly lockEntryUseCase: ILockEntryUseCase; + private readonly getManager: () => Promise; + private readonly getIdentity: IGetIdentity; + + public constructor(params: IUpdateEntryLockUseCaseParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.lockEntryUseCase = params.lockEntryUseCase; + this.getManager = params.getManager; + this.getIdentity = params.getIdentity; + } + + public async execute( + params: IUpdateEntryLockUseCaseExecuteParams + ): Promise { + const record = await this.getLockRecordUseCase.execute(params); + if (!record) { + return this.lockEntryUseCase.execute(params); + } + + validateSameIdentity({ + getIdentity: this.getIdentity, + target: record.lockedBy + }); + + try { + const manager = await this.getManager(); + + const entryId = createLockRecordDatabaseId(record.id); + const id = createIdentifier({ + id: entryId, + version: 1 + }); + const result = await manager.update(id, { + savedOn: new Date().toISOString() + }); + return convertEntryToLockRecord(result); + } catch (ex) { + throw new WebinyError( + `Could not update lock entry: ${ex.message}`, + ex.code || "UPDATE_LOCK_ENTRY_ERROR", + { + ...ex.data + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/index.ts b/packages/api-locking-mechanism/src/useCases/index.ts new file mode 100644 index 00000000000..e76554b45dc --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/index.ts @@ -0,0 +1,120 @@ +import { + IGetIdentity, + IGetWebsocketsContextCallable, + IHasFullAccessCallable, + ILockingMechanismModelManager +} from "~/types"; +import { GetLockRecordUseCase } from "./GetLockRecord/GetLockRecordUseCase"; +import { IsEntryLockedUseCase } from "./IsEntryLocked/IsEntryLockedUseCase"; +import { LockEntryUseCase } from "./LockEntryUseCase/LockEntryUseCase"; +import { UnlockEntryUseCase } from "./UnlockEntryUseCase/UnlockEntryUseCase"; +import { UnlockEntryRequestUseCase } from "./UnlockRequestUseCase/UnlockEntryRequestUseCase"; +import { ListAllLockRecordsUseCase } from "./ListAllLockRecordsUseCase/ListAllLockRecordsUseCase"; +import { ListLockRecordsUseCase } from "./ListLockRecordsUseCase/ListLockRecordsUseCase"; +import { isLockedFactory } from "~/utils/isLockedFactory"; +import { UpdateEntryLockUseCase } from "~/useCases/UpdateEntryLock/UpdateEntryLockUseCase"; +import { getTimeout } from "~/utils/getTimeout"; +import { KickOutCurrentUserUseCase } from "./KickOutCurrentUser/KickOutCurrentUserUseCase"; +import { GetLockedEntryLockRecordUseCase } from "~/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase"; +import { IListAllLockRecordsUseCase } from "~/abstractions/IListAllLockRecordsUseCase"; +import { IListLockRecordsUseCase } from "~/abstractions/IListLockRecordsUseCase"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; +import { IGetLockedEntryLockRecordUseCase } from "~/abstractions/IGetLockedEntryLockRecordUseCase"; +import { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase"; +import { IUpdateEntryLockUseCase } from "~/abstractions/IUpdateEntryLockUseCase"; +import { IUnlockEntryUseCase } from "~/abstractions/IUnlockEntryUseCase"; +import { IUnlockEntryRequestUseCase } from "~/abstractions/IUnlockEntryRequestUseCase"; + +export interface ICreateUseCasesParams { + getIdentity: IGetIdentity; + getManager(): Promise; + hasFullAccess: IHasFullAccessCallable; + getWebsockets: IGetWebsocketsContextCallable; +} + +export interface ICreateUseCasesResponse { + listAllLockRecordsUseCase: IListAllLockRecordsUseCase; + listLockRecordsUseCase: IListLockRecordsUseCase; + getLockRecordUseCase: IGetLockRecordUseCase; + isEntryLockedUseCase: IIsEntryLockedUseCase; + getLockedEntryLockRecordUseCase: IGetLockedEntryLockRecordUseCase; + lockEntryUseCase: ILockEntryUseCase; + updateEntryLockUseCase: IUpdateEntryLockUseCase; + unlockEntryUseCase: IUnlockEntryUseCase; + unlockEntryRequestUseCase: IUnlockEntryRequestUseCase; +} + +export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesResponse => { + const timeout = getTimeout(); + const isLocked = isLockedFactory(timeout); + + const listAllLockRecordsUseCase = new ListAllLockRecordsUseCase({ + getManager: params.getManager + }); + + const listLockRecordsUseCase = new ListLockRecordsUseCase({ + listAllLockRecordsUseCase, + timeout, + getIdentity: params.getIdentity + }); + + const getLockRecordUseCase = new GetLockRecordUseCase({ + getManager: params.getManager + }); + + const isEntryLockedUseCase = new IsEntryLockedUseCase({ + getLockRecordUseCase, + isLocked, + getIdentity: params.getIdentity + }); + + const getLockedEntryLockRecordUseCase = new GetLockedEntryLockRecordUseCase({ + getLockRecordUseCase, + isLocked, + getIdentity: params.getIdentity + }); + + const lockEntryUseCase = new LockEntryUseCase({ + isEntryLockedUseCase, + getManager: params.getManager + }); + + const updateEntryLockUseCase = new UpdateEntryLockUseCase({ + getLockRecordUseCase, + lockEntryUseCase, + getManager: params.getManager, + getIdentity: params.getIdentity + }); + + const kickOutCurrentUserUseCase = new KickOutCurrentUserUseCase({ + getWebsockets: params.getWebsockets, + getIdentity: params.getIdentity + }); + + const unlockEntryUseCase = new UnlockEntryUseCase({ + getLockRecordUseCase, + kickOutCurrentUserUseCase, + getManager: params.getManager, + getIdentity: params.getIdentity, + hasFullAccess: params.hasFullAccess + }); + + const unlockEntryRequestUseCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase, + getIdentity: params.getIdentity, + getManager: params.getManager + }); + + return { + listAllLockRecordsUseCase, + listLockRecordsUseCase, + getLockRecordUseCase, + isEntryLockedUseCase, + getLockedEntryLockRecordUseCase, + lockEntryUseCase, + updateEntryLockUseCase, + unlockEntryUseCase, + unlockEntryRequestUseCase + }; +}; diff --git a/packages/api-locking-mechanism/src/utils/calculateExpiresOn.ts b/packages/api-locking-mechanism/src/utils/calculateExpiresOn.ts new file mode 100644 index 00000000000..0897986577b --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/calculateExpiresOn.ts @@ -0,0 +1,10 @@ +import { IHeadlessCmsLockRecordParams } from "./convertEntryToLockRecord"; +import { getTimeout } from "./getTimeout"; + +export const calculateExpiresOn = (input: Pick): Date => { + const timeout = getTimeout(); + + const savedOn = new Date(input.savedOn); + + return new Date(savedOn.getTime() + timeout); +}; diff --git a/packages/api-locking-mechanism/src/utils/checkPermissions.ts b/packages/api-locking-mechanism/src/utils/checkPermissions.ts new file mode 100644 index 00000000000..83aa25ed3ab --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/checkPermissions.ts @@ -0,0 +1,14 @@ +import { NotAuthorizedError } from "@webiny/api-security"; +import { Context } from "~/types"; + +/** + * Simple permission check. Only full access can access the websockets API via GraphQL - ({name: "*"}) + * + * @throws + */ +export const checkPermissions = async (context: Pick): Promise => { + const identity = context.security.getIdentity(); + if (!identity.id) { + throw new NotAuthorizedError(); + } +}; diff --git a/packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts b/packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts new file mode 100644 index 00000000000..fc6d4096348 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts @@ -0,0 +1,129 @@ +import { CmsEntry, ILockingMechanismIdentity } from "~/types"; +import { + ILockingMechanismLockRecord, + ILockingMechanismLockRecordAction, + ILockingMechanismLockRecordActionType, + ILockingMechanismLockRecordApprovedAction, + ILockingMechanismLockRecordDeniedAction, + ILockingMechanismLockRecordEntryType, + ILockingMechanismLockRecordObject, + ILockingMechanismLockRecordRequestedAction, + ILockingMechanismLockRecordValues +} from "~/types"; +import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId"; +import { calculateExpiresOn } from "~/utils/calculateExpiresOn"; + +export const convertEntryToLockRecord = ( + entry: CmsEntry +): ILockingMechanismLockRecord => { + return new HeadlessCmsLockRecord(entry); +}; + +export type IHeadlessCmsLockRecordParams = Pick< + CmsEntry, + "entryId" | "values" | "createdBy" | "createdOn" | "savedOn" +>; + +export class HeadlessCmsLockRecord implements ILockingMechanismLockRecord { + private readonly _id: string; + private readonly _targetId: string; + private readonly _type: ILockingMechanismLockRecordEntryType; + private readonly _lockedBy: ILockingMechanismIdentity; + private readonly _lockedOn: Date; + private readonly _updatedOn: Date; + private readonly _expiresOn: Date; + private _actions?: ILockingMechanismLockRecordAction[]; + + public get id(): string { + return this._id; + } + + public get targetId(): string { + return this._targetId; + } + + public get type(): ILockingMechanismLockRecordEntryType { + return this._type; + } + + public get lockedBy(): ILockingMechanismIdentity { + return this._lockedBy; + } + + public get lockedOn(): Date { + return this._lockedOn; + } + + public get updatedOn(): Date { + return this._updatedOn; + } + + public get expiresOn(): Date { + return this._expiresOn; + } + + public get actions(): ILockingMechanismLockRecordAction[] | undefined { + return this._actions; + } + + public constructor(input: IHeadlessCmsLockRecordParams) { + this._id = removeLockRecordDatabasePrefix(input.entryId); + this._targetId = input.values.targetId; + this._type = input.values.type; + this._lockedBy = input.createdBy; + this._lockedOn = new Date(input.createdOn); + this._updatedOn = new Date(input.savedOn); + this._expiresOn = calculateExpiresOn(input); + this._actions = input.values.actions; + } + + public toObject(): ILockingMechanismLockRecordObject { + return { + id: this._id, + targetId: this._targetId, + type: this._type, + lockedBy: this._lockedBy, + lockedOn: this._lockedOn, + updatedOn: this._updatedOn, + expiresOn: this._expiresOn, + actions: this._actions + }; + } + + public addAction(action: ILockingMechanismLockRecordAction) { + if (!this._actions) { + this._actions = []; + } + this._actions.push(action); + } + + public getUnlockRequested(): ILockingMechanismLockRecordRequestedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is ILockingMechanismLockRecordRequestedAction => + action.type === ILockingMechanismLockRecordActionType.requested + ); + } + + public getUnlockApproved(): ILockingMechanismLockRecordApprovedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is ILockingMechanismLockRecordApprovedAction => + action.type === ILockingMechanismLockRecordActionType.approved + ); + } + + public getUnlockDenied(): ILockingMechanismLockRecordDeniedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is ILockingMechanismLockRecordDeniedAction => + action.type === ILockingMechanismLockRecordActionType.denied + ); + } +} diff --git a/packages/api-locking-mechanism/src/utils/convertWhereCondition.ts b/packages/api-locking-mechanism/src/utils/convertWhereCondition.ts new file mode 100644 index 00000000000..8dbefeb7ac5 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/convertWhereCondition.ts @@ -0,0 +1,37 @@ +import { ILockingMechanismListLockRecordsParams } from "~/types"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; + +type IWhere = ILockingMechanismListLockRecordsParams["where"] | undefined; + +const attachPrefix = (value: string | string[] | undefined) => { + if (!value) { + return value; + } else if (Array.isArray(value)) { + return value.map(createLockRecordDatabaseId); + } + return createLockRecordDatabaseId(value); +}; + +export const convertWhereCondition = (where: IWhere): IWhere => { + if (!where) { + return where; + } + for (const key in where) { + if (key.startsWith("AND") || key.startsWith("OR")) { + const value = where[key] as IWhere[] | undefined; + if (!value) { + continue; + } + for (const subKey in value) { + value[subKey] = convertWhereCondition(value[subKey]); + } + continue; + } else if (key.startsWith("id") === false) { + continue; + } + const newKey = key.replace("id", "entryId"); + where[newKey] = attachPrefix(where[key] as string | string[] | undefined); + delete where[key]; + } + return where; +}; diff --git a/packages/api-locking-mechanism/src/utils/getTimeout.ts b/packages/api-locking-mechanism/src/utils/getTimeout.ts new file mode 100644 index 00000000000..bd18862a6af --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/getTimeout.ts @@ -0,0 +1,13 @@ +const defaultTimeoutInSeconds = 1800; +/** + * In milliseconds. + */ +export const getTimeout = () => { + const userDefined = process.env.WEBINY_RECORD_LOCK_TIMEOUT + ? parseInt(process.env.WEBINY_RECORD_LOCK_TIMEOUT) + : undefined; + if (!userDefined || isNaN(userDefined) || userDefined <= 0) { + return defaultTimeoutInSeconds * 1000; + } + return userDefined * 1000; +}; diff --git a/packages/api-locking-mechanism/src/utils/isLockedFactory.ts b/packages/api-locking-mechanism/src/utils/isLockedFactory.ts new file mode 100644 index 00000000000..01e9fa45126 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/isLockedFactory.ts @@ -0,0 +1,17 @@ +import { ILockingMechanismLockRecord } from "~/types"; + +export interface IIsLocked { + (record?: Pick | null): boolean; +} + +export const isLockedFactory = (timeoutInput: number): IIsLocked => { + const timeout = timeoutInput * 1000; + return record => { + if (!record || record.lockedOn instanceof Date === false) { + return false; + } + const now = new Date().getTime(); + const lockedOn = record.lockedOn.getTime(); + return lockedOn + timeout >= now; + }; +}; diff --git a/packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts b/packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts new file mode 100644 index 00000000000..0c6ad0fdbf8 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts @@ -0,0 +1,15 @@ +import { parseIdentifier } from "@webiny/utils"; + +const WBY_LM_PREFIX = "wby-lm-"; + +export const createLockRecordDatabaseId = (input: string): string => { + const { id } = parseIdentifier(input); + if (id.startsWith(WBY_LM_PREFIX)) { + return id; + } + return `${WBY_LM_PREFIX}${id}`; +}; + +export const removeLockRecordDatabasePrefix = (id: string) => { + return id.replace(WBY_LM_PREFIX, ""); +}; diff --git a/packages/api-locking-mechanism/src/utils/resolve.ts b/packages/api-locking-mechanism/src/utils/resolve.ts new file mode 100644 index 00000000000..cf442720df0 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/resolve.ts @@ -0,0 +1,27 @@ +import { ErrorResponse, ListErrorResponse, ListResponse, Response } from "@webiny/handler-graphql"; +import { ILockingMechanismMeta } from "~/types"; + +export const resolve = async (cb: () => Promise): Promise | ErrorResponse> => { + try { + const result = await cb(); + return new Response(result); + } catch (ex) { + return new ErrorResponse(ex); + } +}; + +export interface IListResponse { + items: T[]; + meta: ILockingMechanismMeta; +} + +export const resolveList = async ( + cb: () => Promise> +): Promise | ErrorResponse> => { + try { + const { items, meta } = await cb(); + return new ListResponse(items, meta); + } catch (ex) { + return new ListErrorResponse(ex); + } +}; diff --git a/packages/api-locking-mechanism/src/utils/validateSameIdentity.ts b/packages/api-locking-mechanism/src/utils/validateSameIdentity.ts new file mode 100644 index 00000000000..35177e07159 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/validateSameIdentity.ts @@ -0,0 +1,19 @@ +import { NotAuthorizedError } from "@webiny/api-security"; +import { ILockingMechanismIdentity } from "~/types"; + +export interface IValidateSameIdentityParams { + getIdentity: () => Pick; + target: Pick; +} + +export const validateSameIdentity = (params: IValidateSameIdentityParams): void => { + const { getIdentity, target } = params; + const identity = getIdentity(); + if (identity.id === target.id) { + return; + } + throw new NotAuthorizedError({ + message: "Cannot update lock record. Record is locked by another user.", + code: "LOCK_UPDATE_ERROR" + }); +}; diff --git a/packages/api-locking-mechanism/tsconfig.build.json b/packages/api-locking-mechanism/tsconfig.build.json new file mode 100644 index 00000000000..f2247edde68 --- /dev/null +++ b/packages/api-locking-mechanism/tsconfig.build.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api/tsconfig.build.json" }, + { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../api-websockets/tsconfig.build.json" }, + { "path": "../error/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-aws/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../pubsub/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, + { "path": "../api-i18n/tsconfig.build.json" }, + { "path": "../api-security/tsconfig.build.json" }, + { "path": "../api-tenancy/tsconfig.build.json" }, + { "path": "../api-wcp/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-locking-mechanism/tsconfig.json b/packages/api-locking-mechanism/tsconfig.json new file mode 100644 index 00000000000..612dc0c33ef --- /dev/null +++ b/packages/api-locking-mechanism/tsconfig.json @@ -0,0 +1,58 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api" }, + { "path": "../api-headless-cms" }, + { "path": "../api-websockets" }, + { "path": "../error" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, + { "path": "../handler-graphql" }, + { "path": "../plugins" }, + { "path": "../pubsub" }, + { "path": "../utils" }, + { "path": "../api-i18n" }, + { "path": "../api-security" }, + { "path": "../api-tenancy" }, + { "path": "../api-wcp" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/api-websockets/*": ["../api-websockets/src/*"], + "@webiny/api-websockets": ["../api-websockets/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/api-i18n/*": ["../api-i18n/src/*"], + "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-security/*": ["../api-security/src/*"], + "@webiny/api-security": ["../api-security/src"], + "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], + "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/api-wcp/*": ["../api-wcp/src/*"], + "@webiny/api-wcp": ["../api-wcp/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-locking-mechanism/webiny.config.js b/packages/api-locking-mechanism/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-locking-mechanism/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json index fc8f0459dcb..661fb2d312d 100644 --- a/packages/api-prerendering-service/package.json +++ b/packages/api-prerendering-service/package.json @@ -25,7 +25,7 @@ "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0", "lodash": "4.17.21", - "object-hash": "^2.1.1", + "object-hash": "^3.0.0", "pluralize": "^8.0.0", "posthtml": "^0.15.0", "posthtml-noopener": "^1.0.5", diff --git a/packages/api-wcp/src/graphql.ts b/packages/api-wcp/src/graphql.ts index a13e20c9674..7474f9ca8f0 100644 --- a/packages/api-wcp/src/graphql.ts +++ b/packages/api-wcp/src/graphql.ts @@ -18,6 +18,7 @@ export const createWcpGraphQL = () => { advancedPublishingWorkflow: WcpProjectPackageFeaturesFeature advancedAccessControlLayer: WcpProjectPackageFeaturesFeature auditLogs: WcpProjectPackageFeaturesFeature + recordLocking: WcpProjectPackageFeaturesFeature } type WcpProjectPackage { diff --git a/packages/api-wcp/src/types.ts b/packages/api-wcp/src/types.ts index bd8c1da2d88..1afead6c1de 100644 --- a/packages/api-wcp/src/types.ts +++ b/packages/api-wcp/src/types.ts @@ -14,6 +14,7 @@ export interface WcpContextObject { canUseTeams: () => boolean; canUsePrivateFiles: () => boolean; canUseFolderLevelPermissions: () => boolean; + canUseRecordLocking: () => boolean; ensureCanUseFeature: (featureId: keyof typeof WCP_FEATURE_LABEL) => void; incrementSeats: () => Promise; decrementSeats: () => Promise; diff --git a/packages/api-websockets/src/context/WebsocketsContext.ts b/packages/api-websockets/src/context/WebsocketsContext.ts index 3beea28c33b..13a005905a3 100644 --- a/packages/api-websockets/src/context/WebsocketsContext.ts +++ b/packages/api-websockets/src/context/WebsocketsContext.ts @@ -1,7 +1,7 @@ import WebinyError from "@webiny/error"; import { IWebsocketsConnectionRegistry, IWebsocketsConnectionRegistryData } from "~/registry"; import { - IWebsocketsContext, + IWebsocketsContextObject, IWebsocketsContextDisconnectConnectionsParams, IWebsocketsContextListConnectionsParams, IWebsocketsIdentity @@ -13,7 +13,7 @@ import { } from "~/transport"; import { GenericRecord } from "@webiny/api/types"; -export class WebsocketsContext implements IWebsocketsContext { +export class WebsocketsContext implements IWebsocketsContextObject { public readonly registry: IWebsocketsConnectionRegistry; private readonly transport: IWebsocketsTransport; @@ -23,7 +23,7 @@ export class WebsocketsContext implements IWebsocketsContext { } public async send( - identity: IWebsocketsIdentity, + identity: Pick, data: IWebsocketsTransportSendData ): Promise { const connections = await this.listConnections({ diff --git a/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts b/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts index bca8110e055..9272b190234 100644 --- a/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts +++ b/packages/api-websockets/src/context/abstractions/IWebsocketsContext.ts @@ -18,11 +18,11 @@ export interface IWebsocketsContextListConnectionsParams { export type IWebsocketsContextDisconnectConnectionsParams = IWebsocketsContextListConnectionsParams; -export interface IWebsocketsContext { +export interface IWebsocketsContextObject { readonly registry: IWebsocketsConnectionRegistry; send( - identity: IWebsocketsIdentity, + identity: Pick, data: IWebsocketsTransportSendData ): Promise; sendToConnections( diff --git a/packages/api-websockets/src/types.ts b/packages/api-websockets/src/types.ts index 37fdec7fc0c..d7c581e2596 100644 --- a/packages/api-websockets/src/types.ts +++ b/packages/api-websockets/src/types.ts @@ -1,10 +1,12 @@ import { DbContext } from "@webiny/handler-db/types"; -import { IWebsocketsContext } from "./context/abstractions/IWebsocketsContext"; +import { IWebsocketsContextObject } from "./context/abstractions/IWebsocketsContext"; import { SecurityContext, SecurityPermission } from "@webiny/api-security/types"; import { I18NContext } from "@webiny/api-i18n/types"; +export type { IWebsocketsContextObject }; + export interface Context extends DbContext, SecurityContext, I18NContext { - websockets: IWebsocketsContext; + websockets: IWebsocketsContextObject; } export interface WebsocketsPermission extends SecurityPermission { diff --git a/packages/app-aco/package.json b/packages/app-aco/package.json index b89260e7e8f..07141697bd3 100644 --- a/packages/app-aco/package.json +++ b/packages/app-aco/package.json @@ -14,7 +14,7 @@ "@apollo/react-hooks": "^3.1.5", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", - "@material-design-icons/svg": "^0.12.1", + "@material-design-icons/svg": "^0.14.2", "@material-symbols/svg-400": "^0.4.1", "@minoru/react-dnd-treeview": "3.2.1", "@webiny/app": "0.0.0", diff --git a/packages/app-admin-rmwc/package.json b/packages/app-admin-rmwc/package.json index 3ebe81466fc..ae6fc87fed5 100644 --- a/packages/app-admin-rmwc/package.json +++ b/packages/app-admin-rmwc/package.json @@ -11,7 +11,7 @@ "dependencies": { "@babel/runtime": "^7.24.0", "@emotion/styled": "^11.10.6", - "@material-design-icons/svg": "^0.12.1", + "@material-design-icons/svg": "^0.14.2", "@rmwc/base": "7.0.3", "@rmwc/provider": "7.0.3", "@types/react": "17.0.39", diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx index 631d1ae0550..d8017e02b6d 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx @@ -1,10 +1,10 @@ import React from "react"; import { FolderProvider, useAcoConfig } from "@webiny/app-aco"; -import { OptionsMenu } from "@webiny/app-admin"; +import { makeDecoratable, OptionsMenu } from "@webiny/app-admin"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; import { EntryProvider } from "~/admin/hooks/useEntry"; -export const CellActions = () => { +const DefaultCellActions = () => { const { useTableRow, isFolderRow } = ContentEntryListConfig.Browser.Table.Column; const { row } = useTableRow(); const { folder: folderConfig, record: recordConfig } = useAcoConfig(); @@ -34,3 +34,5 @@ export const CellActions = () => { ); }; + +export const CellActions = makeDecoratable("CellActions", DefaultCellActions); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx index fd47b1e98b8..f492d44f209 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx @@ -1,9 +1,9 @@ import React, { useCallback } from "react"; -import { useConfirmationDialog } from "@webiny/app-admin"; +import { makeDecoratableHook, useConfirmationDialog } from "@webiny/app-admin"; import { useRevision } from "~/admin/views/contentEntries/ContentEntry/useRevision"; import { useContentEntry } from "~/admin/views/contentEntries/hooks"; -interface ShowConfirmationDialogParams { +export interface ShowConfirmationDialogParams { ev: React.MouseEvent; onAccept?: () => void; onCancel?: () => void; @@ -13,7 +13,7 @@ interface UseSaveAndPublishResponse { showConfirmationDialog: (params: ShowConfirmationDialogParams) => void; } -export const useSaveAndPublish = (): UseSaveAndPublishResponse => { +const useSaveAndPublishDefault = (): UseSaveAndPublishResponse => { const { form, entry } = useContentEntry(); const { publishRevision } = useRevision({ revision: entry }); @@ -24,7 +24,7 @@ export const useSaveAndPublish = (): UseSaveAndPublishResponse => { }); const showConfirmationDialog = useCallback( - async ({ ev, onAccept, onCancel }) => { + async ({ ev, onAccept, onCancel }: ShowConfirmationDialogParams) => { const entry = await form.current.submit(ev); if (!entry || !entry.id) { return; @@ -43,3 +43,5 @@ export const useSaveAndPublish = (): UseSaveAndPublishResponse => { return { showConfirmationDialog }; }; + +export const useSaveAndPublish = makeDecoratableHook(useSaveAndPublishDefault); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveContent/useSave.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveContent/useSave.tsx index 0a0b275dacb..3798fc90ce1 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveContent/useSave.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveContent/useSave.tsx @@ -1,11 +1,12 @@ import React, { useCallback } from "react"; import { useContentEntry } from "~/admin/views/contentEntries/hooks"; +import { makeDecoratableHook } from "@webiny/react-composition"; interface UseSaveResponse { saveEntry: (ev: React.SyntheticEvent) => Promise; } -export const useSave = (): UseSaveResponse => { +const useSaveDefault = (): UseSaveResponse => { const { form, entry } = useContentEntry(); const saveEntry = useCallback( @@ -24,3 +25,5 @@ export const useSave = (): UseSaveResponse => { saveEntry }; }; + +export const useSave = makeDecoratableHook(useSaveDefault); diff --git a/packages/app-headless-cms/src/admin/hooks/usePermission.ts b/packages/app-headless-cms/src/admin/hooks/usePermission.ts index 52ec31fdc69..a2a33bf60b9 100644 --- a/packages/app-headless-cms/src/admin/hooks/usePermission.ts +++ b/packages/app-headless-cms/src/admin/hooks/usePermission.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useSecurity } from "@webiny/app-security"; import { useI18N } from "@webiny/app-i18n/hooks/useI18N"; -import { CmsIdentity, CmsGroup, CmsModel, CmsSecurityPermission } from "~/types"; +import { CmsGroup, CmsIdentity, CmsModel, CmsSecurityPermission } from "~/types"; export interface CreatableItem { createdBy?: Pick; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx index 31068ba1b5c..e4a74107865 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx @@ -1,18 +1,13 @@ import React from "react"; import { css } from "emotion"; import styled from "@emotion/styled"; -import EmptyView from "@webiny/app-admin/components/EmptyView"; -import { ButtonDefault, ButtonIcon } from "@webiny/ui/Button"; -import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; -import { i18n } from "@webiny/app/i18n"; import { Tab, Tabs } from "@webiny/ui/Tabs"; import { Elevation } from "@webiny/ui/Elevation"; import { CircularProgress } from "@webiny/ui/Progress"; import RevisionsList from "./ContentEntry/RevisionsList"; import { useContentEntry } from "./hooks/useContentEntry"; import { ContentEntryForm } from "~/admin/components/ContentEntryForm/ContentEntryForm"; - -const t = i18n.namespace("app-headless-cms/admin/content-model-entries/details"); +import { makeDecoratable } from "@webiny/app"; const DetailsContainer = styled("div")({ height: "calc(100% - 10px)", @@ -47,35 +42,8 @@ declare global { } } -export const ContentEntry = () => { - const { - loading, - entry, - showEmptyView, - canCreate, - createEntry, - activeTab, - setActiveTab, - setFormRef - } = useContentEntry(); - - // Render "No content selected" view. - if (showEmptyView) { - return ( - - } /> {t`New Entry`} - - ) : null - } - /> - ); - } +const DefaultContentEntry = () => { + const { loading, entry, activeTab, setActiveTab, setFormRef } = useContentEntry(); return ( @@ -109,3 +77,5 @@ export const ContentEntry = () => { ); }; + +export const ContentEntry = makeDecoratable("ContentEntry", DefaultContentEntry); diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx index 1fe713ccabb..f4d6b5f5a62 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx @@ -2,17 +2,18 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import debounce from "lodash/debounce"; import omit from "lodash/omit"; import { useRouter } from "@webiny/react-router"; +import { makeDecoratable } from "@webiny/react-composition"; import { useContentEntries } from "./useContentEntries"; import { CmsContentEntry, EntryTableItem, TableItem } from "~/types"; import { OnSortingChange, Sorting } from "@webiny/ui/DataTable"; import { - useAcoList, - createSort, - useNavigateFolder, + createFoldersData, createRecordsData, - createFoldersData + createSort, + useAcoList, + useNavigateFolder } from "@webiny/app-aco"; -import { CMS_ENTRY_LIST_LINK } from "~/admin/constants"; +import { CMS_ENTRY_LIST_LINK, ROOT_FOLDER } from "~/admin/constants"; import { FolderTableItem, ListMeta } from "@webiny/app-aco/types"; interface UpdateSearchCallableParams { @@ -24,6 +25,9 @@ interface UpdateSearchCallable { } export interface ContentEntriesListProviderContext { + modelId: string; + folderId: string; + navigateTo: (folderId?: string) => void; folders: FolderTableItem[]; getEntryEditUrl: (item: EntryTableItem) => string; hideFilters: () => void; @@ -161,7 +165,19 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi setListSort(sort); }, [sorting]); + const navigateTo = useCallback( + (input?: string) => { + const folderId = encodeURIComponent(input || currentFolderId || ROOT_FOLDER); + + history.push(`${baseUrl}?folderId=${folderId}`); + }, + [currentFolderId, baseUrl] + ); + const context: ContentEntriesListProviderContext = { + modelId: contentModel.modelId, + folderId: currentFolderId || ROOT_FOLDER, + navigateTo, folders, getEntryEditUrl, isListLoading, @@ -191,7 +207,7 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi ); }; -export const useContentEntriesList = (): ContentEntriesListProviderContext => { +export const useContentEntriesList = makeDecoratable((): ContentEntriesListProviderContext => { const context = React.useContext(ContentEntriesListContext); if (!context) { @@ -199,4 +215,4 @@ export const useContentEntriesList = (): ContentEntriesListProviderContext => { } return context; -}; +}); diff --git a/packages/app-locking-mechanism/.babelrc.js b/packages/app-locking-mechanism/.babelrc.js new file mode 100644 index 00000000000..bec58b263bd --- /dev/null +++ b/packages/app-locking-mechanism/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForReact({ path: __dirname }); diff --git a/packages/app-locking-mechanism/LICENSE b/packages/app-locking-mechanism/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/app-locking-mechanism/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/app-locking-mechanism/README.md b/packages/app-locking-mechanism/README.md new file mode 100644 index 00000000000..cbb628109b3 --- /dev/null +++ b/packages/app-locking-mechanism/README.md @@ -0,0 +1,12 @@ +# @webiny/app-locking-mechanism +[![](https://img.shields.io/npm/dw/@webiny/app-locking-mechanism.svg)](https://www.npmjs.com/package/@webiny/app-locking-mechanism) +[![](https://img.shields.io/npm/v/@webiny/app-locking-mechanism.svg)](https://www.npmjs.com/package/@webiny/app-locking-mechanism) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +Exposes a simple `SocketsProvider` React provider component and enables you to quickly send socket messages via the `useSockets` React hook. + +## Install +``` +yarn add @webiny/app-locking-mechanism +``` diff --git a/packages/app-locking-mechanism/package.json b/packages/app-locking-mechanism/package.json new file mode 100644 index 00000000000..fde34bb190b --- /dev/null +++ b/packages/app-locking-mechanism/package.json @@ -0,0 +1,58 @@ +{ + "name": "@webiny/app-locking-mechanism", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "contributors": [ + "Pavel Denisjuk ", + "Sven Al Hamad ", + "Adrian Smijulj " + ], + "license": "MIT", + "dependencies": { + "@apollo/react-hooks": "^3.1.5", + "@emotion/styled": "^11.10.6", + "@material-design-icons/svg": "^0.14.13", + "@webiny/app": "0.0.0", + "@webiny/app-admin": "0.0.0", + "@webiny/app-headless-cms": "0.0.0", + "@webiny/app-security": "0.0.0", + "@webiny/app-wcp": "0.0.0", + "@webiny/app-websockets": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/react-router": "0.0.0", + "@webiny/ui": "0.0.0", + "@webiny/utils": "0.0.0", + "apollo-client": "^2.6.10", + "apollo-link": "^1.2.14", + "crypto-hash": "^3.0.0", + "emotion": "^10.0.27", + "graphql-tag": "^2.12.6", + "react": "17.0.2", + "react-dom": "17.0.2" + }, + "devDependencies": { + "@babel/cli": "^7.23.9", + "@babel/core": "^7.24.0", + "@babel/preset-env": "^7.24.0", + "@babel/preset-react": "^7.23.3", + "@babel/preset-typescript": "^7.23.3", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^5.0.5", + "ttypescript": "^1.5.12", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f" +} diff --git a/packages/app-locking-mechanism/src/components/HeadlessCmsActionsAcoCell.tsx b/packages/app-locking-mechanism/src/components/HeadlessCmsActionsAcoCell.tsx new file mode 100644 index 00000000000..33b515d6cce --- /dev/null +++ b/packages/app-locking-mechanism/src/components/HeadlessCmsActionsAcoCell.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { ContentEntryListConfig } from "@webiny/app-headless-cms"; +import { ReactComponent as LockedIcon } from "./assets/lock.svg"; +import { Tooltip } from "@webiny/ui/Tooltip"; +import { useLockingMechanism } from "~/hooks"; +import { UseContentEntriesListHookDecorator } from "./decorators/UseContentEntriesListHookDecorator"; +import { CellActions } from "@webiny/app-headless-cms/admin/components/ContentEntries/Table/Cells"; +import styled from "@emotion/styled"; +import { UseSaveAndPublishHookDecorator } from "~/components/decorators/UseSaveAndPublishHookDecorator"; +import { UseSaveHookDecorator } from "~/components/decorators/UseSaveHookDecorator"; + +const CenterAlignment = styled("div")({ + display: "block", + margin: "0 auto", + width: "28px" +}); + +const LockingMechanismCellActions = CellActions.createDecorator(Original => { + return function LockingMechanismCellActions(props) { + const { getLockRecordEntry, isRecordLocked } = useLockingMechanism(); + + const { useTableRow, isFolderRow } = ContentEntryListConfig.Browser.Table.Column; + const { row } = useTableRow(); + + if (isFolderRow(row)) { + return ; + } + + const entry = getLockRecordEntry(row.id); + + if (!isRecordLocked(entry) || !entry?.$locked) { + return ; + } + return ( + + + + + + ); + }; +}); + +export const HeadlessCmsActionsAcoCell = () => { + return ( + + + + + + + ); +}; diff --git a/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryGuard.tsx b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryGuard.tsx new file mode 100644 index 00000000000..f0ea9fca05a --- /dev/null +++ b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryGuard.tsx @@ -0,0 +1,72 @@ +import { useContentEntry } from "@webiny/app-headless-cms"; +import { useLockingMechanism } from "~/hooks"; +import { Elevation } from "@webiny/ui/Elevation"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { css } from "emotion"; +import styled from "@emotion/styled"; +import React, { useEffect, useState } from "react"; +import { LockedRecord } from "../LockedRecord"; +import { ILockingMechanismLockRecord } from "~/types"; + +const DetailsContainer = styled("div")({ + height: "calc(100% - 10px)", + overflow: "hidden", + position: "relative", + nav: { + backgroundColor: "var(--mdc-theme-surface)" + } +}); + +const RenderBlock = styled("div")({ + position: "relative", + zIndex: 0, + backgroundColor: "var(--mdc-theme-background)", + height: "100%", + padding: 25 +}); + +const elevationStyles = css({ + position: "relative", + height: "100%" +}); + +export interface IContentEntryGuardProps { + children: React.ReactElement; +} + +export const ContentEntryGuard = (props: IContentEntryGuardProps) => { + const { loading, entry, contentModel: model } = useContentEntry(); + const { children } = props; + const { fetchLockedEntryLockRecord } = useLockingMechanism(); + + const [locked, setLocked] = useState(undefined); + + useEffect(() => { + if (!entry.id || loading || locked !== undefined) { + return; + } + (async () => { + const result = await fetchLockedEntryLockRecord({ + id: entry.id, + $lockingType: model.modelId + }); + setLocked(result); + })(); + }, [entry.id, loading]); + + if (locked === undefined) { + return ( + + + + + + + + ); + } else if (locked) { + return ; + } + + return children; +}; diff --git a/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx new file mode 100644 index 00000000000..cca3c26476a --- /dev/null +++ b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx @@ -0,0 +1,112 @@ +import { useContentEntriesList, useContentEntry } from "@webiny/app-headless-cms"; +import React, { useEffect, useRef } from "react"; +import { useLockingMechanism } from "~/hooks"; +import { + IIsRecordLockedParams, + ILockingMechanismIdentity, + ILockingMechanismLockRecord +} from "~/types"; +import { + IncomingGenericData, + IWebsocketsSubscription, + useWebsockets +} from "@webiny/app-websockets"; +import { parseIdentifier } from "@webiny/utils"; +import { useDialogs } from "@webiny/app-admin"; + +export interface IContentEntryLockerProps { + children: React.ReactElement; +} + +export interface IKickOutWebsocketsMessage extends IncomingGenericData { + data: { + record: ILockingMechanismLockRecord; + user: ILockingMechanismIdentity; + }; +} +interface IForceUnlockedProps { + user: ILockingMechanismIdentity; +} +const ForceUnlocked = ({ user }: IForceUnlockedProps) => { + return ( + <> + The entry you were editing was forcefully unlocked by{" "} + {user.displayName || "Unknown user"}. You will now be redirected back to the list of + entries. + + ); +}; + +export const ContentEntryLocker = ({ children }: IContentEntryLockerProps) => { + const { entry, contentModel: model } = useContentEntry(); + const { updateEntryLock, unlockEntry, fetchLockedEntryLockRecord, removeEntryLock } = + useLockingMechanism(); + + const { navigateTo } = useContentEntriesList(); + + const subscription = useRef>(); + + const websockets = useWebsockets(); + + const { showDialog } = useDialogs(); + + useEffect(() => { + if (!entry.id) { + return; + } else if (subscription.current) { + subscription.current.off(); + } + const { id: entryId } = parseIdentifier(entry.id); + + subscription.current = websockets.onMessage( + `lockingMechanism.entry.kickOut.${entryId}`, + async incoming => { + const { user } = incoming.data; + const record: IIsRecordLockedParams = { + id: entryId, + $lockingType: model.modelId + }; + removeEntryLock(record); + showDialog({ + title: "Entry was forcefully unlocked", + content: , + acceptLabel: "Ok", + onClose: undefined, + cancelLabel: undefined + }); + navigateTo(); + } + ); + + return () => { + if (!subscription.current) { + return; + } + subscription.current.off(); + }; + }, [entry.id, navigateTo, model.modelId]); + + useEffect(() => { + if (!entry.id) { + return; + } + + const record: IIsRecordLockedParams = { + id: entry.id, + $lockingType: model.modelId + }; + updateEntryLock(record); + + return () => { + (async () => { + const result = await fetchLockedEntryLockRecord(record); + if (result) { + return; + } + unlockEntry(record); + })(); + }; + }, [entry.id]); + + return children; +}; diff --git a/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/HeadlessCmsContentEntry.tsx b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/HeadlessCmsContentEntry.tsx new file mode 100644 index 00000000000..75537f4dce0 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/HeadlessCmsContentEntry.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { ContentEntry } from "@webiny/app-headless-cms/admin/views/contentEntries/ContentEntry"; +import { ContentEntryGuard } from "./ContentEntryGuard"; +import { ContentEntryLocker } from "./ContentEntryLocker"; +import { useContentEntry } from "@webiny/app-headless-cms"; + +export const HeadlessCmsContentEntry = ContentEntry.createDecorator(Original => { + return function LockingMechanismContentEntry(props) { + const { entry } = useContentEntry(); + /** + * New entry does not have ID yet. + */ + if (!entry?.id) { + return ; + } + /** + * Continue with existing entry. + */ + return ( + + + + + + ); + }; +}); diff --git a/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/index.ts b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/index.ts new file mode 100644 index 00000000000..60512c09c27 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/HeadlessCmsContentEntry/index.ts @@ -0,0 +1 @@ +export * from "./HeadlessCmsContentEntry"; diff --git a/packages/app-locking-mechanism/src/components/LockedRecord/LockedRecord.tsx b/packages/app-locking-mechanism/src/components/LockedRecord/LockedRecord.tsx new file mode 100644 index 00000000000..d59641f08b1 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/LockedRecord/LockedRecord.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { Elevation as BaseElevation } from "@webiny/ui/Elevation"; +import { useLockingMechanism } from "~/hooks"; +import { useContentEntry } from "@webiny/app-headless-cms"; +import { LockedRecordForceUnlock } from "./LockedRecordForceUnlock"; +import { ReactComponent as LockIcon } from "@material-design-icons/svg/outlined/lock.svg"; +import { ILockingMechanismLockRecord } from "~/types"; + +const StyledWrapper = styled("div")({ + width: "50%", + margin: "100px auto 0 auto", + backgroundColor: "var(--mdc-theme-surface)" +}); + +const InnerWrapper = styled("div")({ + display: "flex" +}); + +const Content = styled("div")({ + display: "flex", + flexDirection: "column", + justifyContent: "center" +}); + +const IconBox = styled("div")({ + width: 250, + height: 250, + marginRight: 25, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", + backgroundColor: "var(--mdc-theme-background)", + svg: { + width: 150, + height: 150, + lineHeight: "100%", + display: "block", + fill: "var(--mdc-theme-primary)" + } +}); + +const Bold = styled("span")({ + fontWeight: 600 +}); + +interface IWrapperProps { + children: React.ReactNode; +} + +const Wrapper = ({ children }: IWrapperProps) => { + return ( + + + + + + + {children} + + + + ); +}; + +const StyledTitle = styled("h3")({ + fontSize: 24, + marginBottom: "10px", + fontWeight: "600", + " > em": { + fontStyle: "italic" + } +}); + +const Title = () => { + const { entry } = useContentEntry(); + return Record ({entry.meta.title}) is locked!; +}; + +const Text = styled("p")({ + fontSize: 16, + marginBottom: "10px", + lineHeight: "125%" +}); + +const Elevation = styled(BaseElevation)({ + padding: "20px" +}); + +export interface ILockedRecordProps { + record: ILockingMechanismLockRecord; +} + +export const LockedRecord = ({ record: lockRecordEntry }: ILockedRecordProps) => { + const { getLockRecordEntry } = useLockingMechanism(); + + const record = getLockRecordEntry(lockRecordEntry.id); + + if (!record) { + return ( + + Could not find the lock record. Please refresh the Admin UI. + + ); + } else if (!lockRecordEntry?.lockedBy) { + return ( + + + <Text> + This record is locked, but the system cannot find the user that created the + record lock. + </Text> + <Text>A force-unlock is required to regain edit capabilities for this record.</Text> + <LockedRecordForceUnlock + id={lockRecordEntry.id} + type={record.$lockingType} + title={record.meta.title} + /> + </Wrapper> + ); + } + return ( + <Wrapper> + <Title /> + <Text> + It is locked because <Bold>{lockRecordEntry.lockedBy.displayName}</Bold> is + currently editing this record. + </Text> + <Text> + You can either contact the user and ask them to unlock the record, or you can wait + for the lock to expire. + </Text> + <LockedRecordForceUnlock + id={lockRecordEntry.id} + type={record.$lockingType} + lockedBy={lockRecordEntry.lockedBy} + title={record.meta.title} + /> + </Wrapper> + ); +}; diff --git a/packages/app-locking-mechanism/src/components/LockedRecord/LockedRecordForceUnlock.tsx b/packages/app-locking-mechanism/src/components/LockedRecord/LockedRecordForceUnlock.tsx new file mode 100644 index 00000000000..14f9efd6f90 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/LockedRecord/LockedRecordForceUnlock.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useEffect, useState } from "react"; +import styled from "@emotion/styled"; +import { ILockingMechanismError, ILockingMechanismIdentity } from "~/types"; +import { useConfirmationDialog, useSnackbar } from "@webiny/app-admin"; +import { useLockingMechanism, usePermission } from "~/hooks"; +import { useRouter } from "@webiny/react-router"; +import { useContentEntriesList } from "@webiny/app-headless-cms"; +import { Alert } from "@webiny/ui/Alert"; +import { ButtonPrimary } from "@webiny/ui/Button"; + +const Wrapper = styled("div")({ + padding: "0", + backgroundColor: "white" +}); + +const Text = styled("p")({ + lineHeight: "125%" +}); + +const Bold = styled("span")({ + fontWeight: 600 +}); + +export interface ILockedRecordForceUnlockProps { + id: string; + type: string; + title: string; + lockedBy?: ILockingMechanismIdentity; +} + +const ErrorMessage = (props: ILockedRecordForceUnlockProps) => { + const { title, lockedBy } = props; + return ( + <div> + <Alert type="warning" title="Warning"> + <Bold>{lockedBy?.displayName || "Unknown user"}</Bold> is currently editing this + record. + <br /> If you force unlock it, they could potentially lose their changes. + </Alert> + <br /> + <p> + You are about to forcefully unlock the <Bold>{title}</Bold> entry. + </p> + <p>Are you sure you want to continue?</p> + </div> + ); +}; + +export const LockedRecordForceUnlock = (props: ILockedRecordForceUnlockProps) => { + const { unlockEntryForce } = useLockingMechanism(); + + const { navigateTo } = useContentEntriesList(); + const { showConfirmation: showForceUnlockConfirmation } = useConfirmationDialog({ + title: "Force unlock the entry", + message: <ErrorMessage {...props} /> + }); + const { showSnackbar } = useSnackbar(); + + const { history } = useRouter(); + + const [error, setError] = useState<ILockingMechanismError>(); + + useEffect(() => { + if (!error?.message) { + return; + } + console.error(error); + showSnackbar(error.message); + }, [error?.message]); + + const onClick = useCallback(() => { + showForceUnlockConfirmation(async () => { + const result = await unlockEntryForce({ + id: props.id, + $lockingType: props.type + }); + if (!result.error) { + navigateTo(); + return; + } + setError(result.error); + }); + }, [props.id, history, navigateTo]); + + const { hasFullAccess } = usePermission(); + if (!hasFullAccess) { + return null; + } + + return ( + <Wrapper> + <Text> + Because you have a full access to the system, you can force unlock the record. + </Text> + <br /> + <ButtonPrimary onClick={onClick}>Unlock and go back</ButtonPrimary> + </Wrapper> + ); +}; diff --git a/packages/app-locking-mechanism/src/components/LockedRecord/index.ts b/packages/app-locking-mechanism/src/components/LockedRecord/index.ts new file mode 100644 index 00000000000..9908b0c25e6 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/LockedRecord/index.ts @@ -0,0 +1 @@ +export * from "./LockedRecord"; diff --git a/packages/app-locking-mechanism/src/components/LockingMechanismProvider.tsx b/packages/app-locking-mechanism/src/components/LockingMechanismProvider.tsx new file mode 100644 index 00000000000..04b4686fe42 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/LockingMechanismProvider.tsx @@ -0,0 +1,139 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { createLockingMechanism } from "~/domain/LockingMechanism"; +import { + IFetchLockedEntryLockRecordParams, + IFetchLockRecordParams, + ILockingMechanismContext, + ILockingMechanismError, + IPossiblyLockingMechanismRecord, + IUnlockEntryParams, + IUpdateEntryLockParams +} from "~/types"; +import { useStateIfMounted } from "@webiny/app-admin"; + +export interface ILockingMechanismProviderProps { + children: React.ReactNode; +} + +export const LockingMechanismContext = React.createContext( + {} as unknown as ILockingMechanismContext +); + +const isSameArray = ( + existingRecords: Pick<IPossiblyLockingMechanismRecord, "id">[], + newRecords: Pick<IPossiblyLockingMechanismRecord, "id">[] +): boolean => { + if (existingRecords.length !== newRecords.length) { + return false; + } + return existingRecords.every(record => { + return newRecords.some(newRecord => newRecord.id === record.id); + }); +}; + +export const LockingMechanismProvider = (props: ILockingMechanismProviderProps) => { + const client = useApolloClient(); + + const [loading, setLoading] = useState(false); + + const lockingMechanism = useMemo(() => { + return createLockingMechanism({ + client, + setLoading + }); + }, []); + + const [error, setError] = useStateIfMounted<ILockingMechanismError | null>(null); + + const [records, setRecords] = useStateIfMounted<IPossiblyLockingMechanismRecord[]>([]); + + const setRecordsIfNeeded = useCallback( + (newRecords: IPossiblyLockingMechanismRecord[]) => { + const sameArray = isSameArray(records, newRecords); + if (sameArray) { + return; + } + setRecords(newRecords); + }, + [records] + ); + + const value: ILockingMechanismContext = { + async updateEntryLock(params: IUpdateEntryLockParams) { + const result = await lockingMechanism.updateEntryLock(params); + if (result.error) { + setError(result.error); + return; + } + const target = result.data; + if (!target?.id) { + setError({ + message: "No data returned from server.", + code: "NO_DATA" + }); + return; + } + + setRecords(prev => { + return prev.map(item => { + if (item.entryId === target.id) { + return { + ...item, + $locked: result.data + }; + } + return item; + }); + }); + }, + async unlockEntry(params: IUnlockEntryParams) { + return await lockingMechanism.unlockEntry(params); + }, + async unlockEntryForce(params: IUnlockEntryParams) { + return await lockingMechanism.unlockEntry(params, true); + }, + isLockExpired(input: Date | string): boolean { + return lockingMechanism.isLockExpired(input); + }, + isRecordLocked(record) { + if (!record) { + return false; + } + return lockingMechanism.isRecordLocked(record); + }, + getLockRecordEntry(id: string) { + return lockingMechanism.getLockRecordEntry(id); + }, + removeEntryLock(params: IUnlockEntryParams) { + return lockingMechanism.removeEntryLock(params); + }, + async fetchLockRecord(params: IFetchLockRecordParams) { + try { + return await lockingMechanism.fetchLockRecord(params); + } catch (ex) { + return { + data: null, + error: ex + }; + } + }, + async fetchLockedEntryLockRecord(params: IFetchLockedEntryLockRecordParams) { + return lockingMechanism.fetchLockedEntryLockRecord(params); + }, + async setRecords(folderId, type, newRecords) { + setRecordsIfNeeded(newRecords); + + const result = await lockingMechanism.setRecords(folderId, type, newRecords); + if (!result) { + return; + } + setRecords(result); + }, + error, + records, + loading + }; + + return <LockingMechanismContext.Provider {...props} value={value} />; +}; diff --git a/packages/app-locking-mechanism/src/components/assets/lock.svg b/packages/app-locking-mechanism/src/components/assets/lock.svg new file mode 100644 index 00000000000..cc0495c35d7 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/assets/lock.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <g stroke-width="1" fill="none" fill-rule="evenodd"> + <g> + <g> + <polygon points="0 0 24 0 24 24 0 24"/> + <polygon opacity="0.87" points="0 0 24 0 24 24 0 24"/> + </g> + <path d="M18,8 L17,8 L17,6 C17,3.24 14.76,1 12,1 C9.24,1 7,3.24 7,6 L7,8 L6,8 C4.9,8 4,8.9 4,10 L4,20 C4,21.1 4.9,22 6,22 L18,22 C19.1,22 20,21.1 20,20 L20,10 C20,8.9 19.1,8 18,8 Z M12,17 C10.9,17 10,16.1 10,15 C10,13.9 10.9,13 12,13 C13.1,13 14,13.9 14,15 C14,16.1 13.1,17 12,17 Z M9,8 L9,6 C9,4.34 10.34,3 12,3 C13.66,3 15,4.34 15,6 L15,8 L9,8 Z" + fill="#D8D8D8" fill-rule="nonzero"/> + </g> + </g> +</svg> diff --git a/packages/app-locking-mechanism/src/components/decorators/UseContentEntriesListHookDecorator.ts b/packages/app-locking-mechanism/src/components/decorators/UseContentEntriesListHookDecorator.ts new file mode 100644 index 00000000000..24281550ee3 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/decorators/UseContentEntriesListHookDecorator.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { useContentEntriesList } from "@webiny/app-headless-cms"; +import { useLockingMechanism } from "~/hooks"; + +export const UseContentEntriesListHookDecorator = useContentEntriesList.createDecorator( + originalHook => { + return function LockingMechanismUseContentEntriesList() { + const value = originalHook(); + const lockingMechanism = useLockingMechanism(); + + useEffect(() => { + if (!value.records) { + return; + } + + lockingMechanism.setRecords(value.folderId, value.modelId, value.records); + }, [value.folderId, value.modelId, value.records, lockingMechanism]); + + return { + ...value, + records: lockingMechanism.records + }; + }; + } +); diff --git a/packages/app-locking-mechanism/src/components/decorators/UseSaveAndPublishHookDecorator.tsx b/packages/app-locking-mechanism/src/components/decorators/UseSaveAndPublishHookDecorator.tsx new file mode 100644 index 00000000000..28656e96165 --- /dev/null +++ b/packages/app-locking-mechanism/src/components/decorators/UseSaveAndPublishHookDecorator.tsx @@ -0,0 +1,52 @@ +import { useCallback } from "react"; +import { + ShowConfirmationDialogParams, + useSaveAndPublish +} from "@webiny/app-headless-cms/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish"; +import { useLockingMechanism } from "~/hooks"; +import { useContentEntry } from "@webiny/app-headless-cms"; +import { useSnackbar } from "@webiny/app-admin"; + +export const UseSaveAndPublishHookDecorator = useSaveAndPublish.createDecorator(originalHook => { + return function useLockingMechanismUseSaveAndPublish() { + const values = originalHook(); + const { entry, contentModel: model } = useContentEntry(); + const { fetchLockedEntryLockRecord, updateEntryLock } = useLockingMechanism(); + const { showSnackbar } = useSnackbar(); + + const showConfirmationDialog = useCallback( + (params: ShowConfirmationDialogParams) => { + (async () => { + if (!entry.id) { + return values.showConfirmationDialog(params); + } + const result = await fetchLockedEntryLockRecord({ + id: entry.id, + $lockingType: model.modelId + }); + + if (result?.lockedBy) { + const lockedBy = result.lockedBy; + showSnackbar( + `It seems that the entry is locked by ${ + lockedBy.displayName || lockedBy.id + }. You cannot save the values you changed.` + ); + return; + } + values.showConfirmationDialog(params); + updateEntryLock({ + id: entry.id, + $lockingType: model.modelId + }); + })(); + }, + [values.showConfirmationDialog, entry?.id, model.modelId] + ); + + return { + ...values, + showConfirmationDialog + }; + }; +}); diff --git a/packages/app-locking-mechanism/src/components/decorators/UseSaveHookDecorator.tsx b/packages/app-locking-mechanism/src/components/decorators/UseSaveHookDecorator.tsx new file mode 100644 index 00000000000..c2cd7ac63bc --- /dev/null +++ b/packages/app-locking-mechanism/src/components/decorators/UseSaveHookDecorator.tsx @@ -0,0 +1,47 @@ +import { useCallback } from "react"; +import { useLockingMechanism } from "~/hooks"; +import { useContentEntry } from "@webiny/app-headless-cms"; +import { useSnackbar } from "@webiny/app-admin"; +import { useSave } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/Header/SaveContent/useSave"; + +export const UseSaveHookDecorator = useSave.createDecorator(originalHook => { + return function useLockingMechanismUseSave() { + const values = originalHook(); + const { entry, contentModel: model } = useContentEntry(); + const { fetchLockedEntryLockRecord, updateEntryLock } = useLockingMechanism(); + const { showSnackbar } = useSnackbar(); + + const saveEntry = useCallback( + async (ev: React.SyntheticEvent) => { + if (!entry.id) { + return values.saveEntry(ev); + } + const result = await fetchLockedEntryLockRecord({ + id: entry.id, + $lockingType: model.modelId + }); + + if (result?.lockedBy) { + const lockedBy = result.lockedBy; + showSnackbar( + `It seems that the entry is locked by ${ + lockedBy.displayName || lockedBy.id + }. You cannot save the values you changed.` + ); + return; + } + await values.saveEntry(ev); + await updateEntryLock({ + id: entry.id, + $lockingType: model.modelId + }); + }, + [values.saveEntry, entry?.id, model.modelId, updateEntryLock] + ); + + return { + ...values, + saveEntry + }; + }; +}); diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanism.ts b/packages/app-locking-mechanism/src/domain/LockingMechanism.ts new file mode 100644 index 00000000000..310fe5d8e43 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanism.ts @@ -0,0 +1,438 @@ +import { + ILockingMechanism, + ILockingMechanismUpdateEntryLockResult +} from "./abstractions/ILockingMechanism"; +import { ApolloClient } from "apollo-client"; +import { LockingMechanismGetLockRecord } from "./LockingMechanismGetLockRecord"; +import { LockingMechanismIsEntryLocked } from "./LockingMechanismIsEntryLocked"; +import { LockingMechanismListLockRecords } from "./LockingMechanismListLockRecords"; +import { LockingMechanismLockEntry } from "./LockingMechanismLockEntry"; +import { LockingMechanismUnlockEntry } from "./LockingMechanismUnlockEntry"; +import { LockingMechanismUnlockEntryRequest } from "./LockingMechanismUnlockEntryRequest"; +import { LockingMechanismClient } from "./LockingMechanismClient"; +import { ILockingMechanismGetLockRecord } from "./abstractions/ILockingMechanismGetLockRecord"; +import { ILockingMechanismIsEntryLocked } from "./abstractions/ILockingMechanismIsEntryLocked"; +import { + ILockingMechanismListLockRecords, + ILockingMechanismListLockRecordsResult +} from "./abstractions/ILockingMechanismListLockRecords"; +import { ILockingMechanismLockEntry } from "./abstractions/ILockingMechanismLockEntry"; +import { + ILockingMechanismUnlockEntry, + ILockingMechanismUnlockEntryResult +} from "./abstractions/ILockingMechanismUnlockEntry"; +import { ILockingMechanismUnlockEntryRequest } from "./abstractions/ILockingMechanismUnlockEntryRequest"; +import { + IFetchLockedEntryLockRecordParams, + IFetchLockRecordParams, + IFetchLockRecordResult, + IIsRecordLockedParams, + ILockingMechanismError, + ILockingMechanismLockRecord, + ILockingMechanismRecord, + IPossiblyLockingMechanismRecord, + IUnlockEntryParams, + IUpdateEntryLockParams +} from "~/types"; +import { ILockingMechanismClient } from "./abstractions/ILockingMechanismClient"; +import { createLockingMechanismError } from "./utils/createLockingMechanismError"; +import { parseIdentifier } from "@webiny/utils/parseIdentifier"; +import { createCacheKey } from "~/utils/createCacheKey"; +import { LockingMechanismUpdateEntryLock } from "~/domain/LockingMechanismUpdateEntryLock"; +import { ILockingMechanismUpdateEntryLock } from "~/domain/abstractions/ILockingMechanismUpdateEntryLock"; +import { LockingMechanismGetLockedEntryLockRecord } from "~/domain/LockingMechanismGetLockedEntryLockRecord"; +import { ILockingMechanismGetLockedEntryLockRecord } from "./abstractions/ILockingMechanismGetLockedEntryLockRecord"; + +export interface ICreateLockingMechanismParams { + client: ApolloClient<any>; + setLoading: (loading: boolean) => void; +} + +export interface ILockingMechanismParams { + client: ILockingMechanismClient; + setLoading: (loading: boolean) => void; + getLockRecord: ILockingMechanismGetLockRecord; + getLockedEntryLockRecord: ILockingMechanismGetLockedEntryLockRecord; + isEntryLocked: ILockingMechanismIsEntryLocked; + listLockRecords: ILockingMechanismListLockRecords; + lockEntry: ILockingMechanismLockEntry; + unlockEntry: ILockingMechanismUnlockEntry; + unlockEntryRequest: ILockingMechanismUnlockEntryRequest; + updateEntryLock: ILockingMechanismUpdateEntryLock; +} + +export interface IOnErrorCb { + (error: ILockingMechanismError): void; +} + +class LockingMechanism<T extends IPossiblyLockingMechanismRecord = IPossiblyLockingMechanismRecord> + implements ILockingMechanism<T> +{ + private currentRecordType?: string; + private currentFolderId?: string; + private currentRecordsCacheKey?: string; + private readonly _setLoading: (loading: boolean) => void; + public loading = false; + public records: ILockingMechanismRecord[] = []; + + private readonly client: ILockingMechanismClient; + private readonly _getLockRecord: ILockingMechanismGetLockRecord; + private readonly _isEntryLocked: ILockingMechanismIsEntryLocked; + private readonly _getLockedEntryLockRecord: ILockingMechanismGetLockedEntryLockRecord; + private readonly _listLockRecords: ILockingMechanismListLockRecords; + private readonly _lockEntry: ILockingMechanismLockEntry; + private readonly _unlockEntry: ILockingMechanismUnlockEntry; + private readonly _unlockEntryRequest: ILockingMechanismUnlockEntryRequest; + private readonly _updateEntryLock: ILockingMechanismUpdateEntryLock; + + private onErrorCb: IOnErrorCb | null = null; + + public constructor(params: ILockingMechanismParams) { + this.client = params.client; + this._setLoading = params.setLoading; + this._getLockRecord = params.getLockRecord; + this._getLockedEntryLockRecord = params.getLockedEntryLockRecord; + this._isEntryLocked = params.isEntryLocked; + this._listLockRecords = params.listLockRecords; + this._lockEntry = params.lockEntry; + this._unlockEntry = params.unlockEntry; + this._unlockEntryRequest = params.unlockEntryRequest; + this._updateEntryLock = params.updateEntryLock; + } + + public async setRecords( + folderId: string, + type: string, + records: T[] + ): Promise<ILockingMechanismRecord[] | undefined> { + const result = await this.fetchAndAssignRecords(folderId, type, records); + if (!result) { + return undefined; + } + + return result.map(record => { + const { id: entryId } = parseIdentifier(record.id); + return { + ...record, + $lockingType: type, + $locked: record.$locked, + $selectable: record.$locked ? false : record.$selectable, + entryId + }; + }); + } + + public async fetchLockRecord(params: IFetchLockRecordParams): Promise<IFetchLockRecordResult> { + const { id, $lockingType } = params; + + const { id: entryId } = parseIdentifier(id); + + try { + const result = await this._getLockRecord.execute({ + id: entryId, + type: $lockingType + }); + + return { + data: result.data, + error: result.error + }; + } catch (ex) { + return { + data: null, + error: ex + }; + } + } + + public async fetchLockedEntryLockRecord( + params: IFetchLockedEntryLockRecordParams + ): Promise<ILockingMechanismLockRecord | null> { + const { id, $lockingType } = params; + + const { id: entryId } = parseIdentifier(id); + const result = await this._getLockedEntryLockRecord.execute({ + id: entryId, + type: $lockingType + }); + return result.data; + } + + public getLockRecordEntry(id: string): ILockingMechanismRecord | undefined { + return this.records.find(record => { + const { id: entryId } = parseIdentifier(id); + return record.entryId === entryId; + }); + } + + public isRecordLocked(record: IIsRecordLockedParams): boolean { + const result = this.records.find(r => { + const { id: entryId } = parseIdentifier(record.id); + + return r.entryId === entryId && !!r.$locked && r.$lockingType === record.$lockingType; + }); + if (!result?.$locked?.expiresOn) { + return false; + } + const isExpired = this.isLockExpired(result.$locked.expiresOn); + return !isExpired; + } + + public async updateEntryLock( + params: IUpdateEntryLockParams + ): Promise<ILockingMechanismUpdateEntryLockResult> { + try { + return await this._updateEntryLock.execute({ + id: params.id, + type: params.$lockingType + }); + } catch (ex) { + this.triggerOnError(ex); + return { + data: null, + error: ex + }; + } + } + + public removeEntryLock(params: IUnlockEntryParams): void { + const index = this.records.findIndex(record => { + return record.entryId === params.id && record.$lockingType === params.$lockingType; + }); + if (index === -1) { + return; + } + this.records[index] = { + ...this.records[index], + $locked: null, + $selectable: true + }; + } + + public async unlockEntry( + params: IUnlockEntryParams, + force?: boolean + ): Promise<ILockingMechanismUnlockEntryResult> { + try { + const result = await this._unlockEntry.execute({ + id: params.id, + type: params.$lockingType, + force + }); + + const id = result.data?.id; + if (!id) { + return result; + } + const index = this.records.findIndex(r => r.entryId === id); + if (index === -1) { + return result; + } + + this.records[index] = { + ...this.records[index], + $locked: undefined, + $selectable: true + }; + + return result; + } catch (ex) { + this.triggerOnError(ex); + return { + data: null, + error: ex + }; + } + } + + public onError(cb: IOnErrorCb): void { + this.onErrorCb = cb; + } + + public triggerOnError(error: ILockingMechanismError): void { + this.setIsLoading(false); + if (!this.onErrorCb) { + return; + } + this.onErrorCb(error); + } + + public isLockExpired(input: Date | string): boolean { + const expiresOn = new Date(input); + return expiresOn <= new Date(); + } + + private setIsLoading(loading: boolean): void { + this._setLoading(loading); + this.loading = loading; + } + + private async fetchAndAssignRecords( + folderId: string, + type: string, + records: T[] + ): Promise<IPossiblyLockingMechanismRecord[] | undefined> { + if (records.length === 0) { + return; + } else if (this.loading) { + return; + } + const assignedIdList = await this.assignRecords(folderId, type, records); + if (assignedIdList.length === 0) { + return; + } + this.setIsLoading(true); + let result: ILockingMechanismListLockRecordsResult; + try { + result = await this._listLockRecords.execute({ + where: { + id_in: assignedIdList, + type + }, + limit: 10000 + }); + } catch (ex) { + console.error(ex); + this.triggerOnError(ex); + return; + } finally { + this.setIsLoading(false); + } + if (result.error) { + this.triggerOnError(result.error); + return; + } else if (!result.data) { + this.triggerOnError( + createLockingMechanismError({ + message: `There is no data in the result and there is no error. Please check the network tab for more info.`, + code: "NO_DATA_IN_RESULT" + }) + ); + return; + } else if (result.data.length === 0) { + return; + } + + for (const record of result.data) { + const index = this.records.findIndex(r => { + const { id: entryId } = parseIdentifier(record.id); + return r.entryId === entryId; + }); + if (index < 0) { + console.error(`There is no record with id ${record.id} in the records array.`); + continue; + } + this.records[index] = { + ...this.records[index], + $locked: { + lockedBy: record.lockedBy, + expiresOn: record.expiresOn, + lockedOn: record.lockedOn, + actions: record.actions + } + }; + } + + return this.records; + } + /** + * Assign records and return the assigned ID list. + */ + private async assignRecords(folderId: string, type: string, records: T[]): Promise<string[]> { + /** + * First we check the record keys against ones in the local cache. + */ + const keys = records.map(record => { + if (record.entryId) { + return record.entryId; + } + const { id: entryId } = parseIdentifier(record.id); + return entryId; + }); + const cacheKey = await createCacheKey(keys); + if (this.currentRecordsCacheKey === cacheKey) { + return []; + } + this.currentRecordsCacheKey = cacheKey; + + /** + * Reset records if new type is not as same as the old type / folderId. + */ + if (this.currentRecordType !== type || this.currentFolderId !== folderId) { + this.records = []; + this.currentRecordType = type; + this.currentFolderId = folderId; + } + + return records.reduce<string[]>((collection, record) => { + const { id: entryId } = parseIdentifier(record.id); + const index = this.records.findIndex(r => r.entryId === entryId); + if (index >= 0) { + return collection; + } + this.records.push({ + ...record, + entryId, + $lockingType: type, + $locked: undefined + }); + if (record.$type !== "RECORD") { + return collection; + } + collection.push(entryId); + return collection; + }, []); + } +} + +export const createLockingMechanism = <T extends ILockingMechanismRecord>( + config: ICreateLockingMechanismParams +): ILockingMechanism => { + const client = new LockingMechanismClient({ + client: config.client + }); + + const getLockRecord = new LockingMechanismGetLockRecord({ + client + }); + + const getLockedEntryLockRecord = new LockingMechanismGetLockedEntryLockRecord({ + client + }); + + const isEntryLocked = new LockingMechanismIsEntryLocked({ + client + }); + + const listLockRecords = new LockingMechanismListLockRecords({ + client + }); + + const lockEntry = new LockingMechanismLockEntry({ + client + }); + + const unlockEntry = new LockingMechanismUnlockEntry({ + client + }); + const unlockEntryRequest = new LockingMechanismUnlockEntryRequest({ + client + }); + + const updateEntryLock = new LockingMechanismUpdateEntryLock({ + client + }); + + return new LockingMechanism<T>({ + client, + setLoading: config.setLoading, + getLockRecord, + getLockedEntryLockRecord, + isEntryLocked, + listLockRecords, + updateEntryLock, + lockEntry, + unlockEntry, + unlockEntryRequest + }); +}; diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismClient.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismClient.ts new file mode 100644 index 00000000000..2304a511481 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismClient.ts @@ -0,0 +1,29 @@ +import { ApolloClient, ApolloQueryResult, MutationOptions, QueryOptions } from "apollo-client"; +import { FetchResult } from "apollo-link"; +import { ILockingMechanismClient } from "~/domain/abstractions/ILockingMechanismClient"; + +export interface ILockingMechanismClientParams { + client: ApolloClient<any>; +} + +export class LockingMechanismClient implements ILockingMechanismClient { + private readonly client: ApolloClient<any>; + + public constructor(params: ILockingMechanismClientParams) { + this.client = params.client; + } + + public async query<T, R>(params: QueryOptions<R>): Promise<ApolloQueryResult<T>> { + return this.client.query<T, R>({ + ...params, + fetchPolicy: "network-only" + }); + } + + public async mutation<T, R>(options: MutationOptions<T, R>): Promise<FetchResult<T>> { + return this.client.mutate<T, R>({ + ...options, + fetchPolicy: "no-cache" + }); + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismGetLockRecord.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismGetLockRecord.ts new file mode 100644 index 00000000000..345cd0cc354 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismGetLockRecord.ts @@ -0,0 +1,41 @@ +import { + ILockingMechanismGetLockRecord, + ILockingMechanismGetLockRecordExecuteParams, + ILockingMechanismGetLockRecordExecuteResult +} from "~/domain/abstractions/ILockingMechanismGetLockRecord"; +import { ILockingMechanismClient } from "~/domain/abstractions/ILockingMechanismClient"; +import { + GET_LOCK_RECORD_QUERY, + ILockingMechanismGetLockRecordResponse, + ILockingMechanismGetLockRecordVariables +} from "~/domain/graphql/getLockRecord"; +import { WebinyError } from "@webiny/error"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismGetLockRecord implements ILockingMechanismGetLockRecord { + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + public async execute( + params: ILockingMechanismGetLockRecordExecuteParams + ): Promise<ILockingMechanismGetLockRecordExecuteResult> { + const result = await this.client.query< + ILockingMechanismGetLockRecordResponse, + ILockingMechanismGetLockRecordVariables + >({ + query: GET_LOCK_RECORD_QUERY, + variables: params + }); + if (result.data.lockingMechanism.getLockRecord.error) { + throw new WebinyError(result.data.lockingMechanism.getLockRecord.error); + } else if (!result.data.lockingMechanism.getLockRecord.data) { + throw new WebinyError("No data returned from server."); + } + return result.data.lockingMechanism.getLockRecord; + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismGetLockedEntryLockRecord.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismGetLockedEntryLockRecord.ts new file mode 100644 index 00000000000..d534fd2ea44 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismGetLockedEntryLockRecord.ts @@ -0,0 +1,41 @@ +import { ILockingMechanismClient } from "~/domain/abstractions/ILockingMechanismClient"; +import { + GET_LOCKED_ENTRY_LOCK_RECORD_QUERY, + ILockingMechanismGetLockedEntryLockRecordResponse, + ILockingMechanismGetLockedEntryLockRecordVariables +} from "~/domain/graphql/getLockedEntryLockRecord"; +import { WebinyError } from "@webiny/error"; +import { + ILockingMechanismGetLockedEntryLockRecord, + ILockingMechanismGetLockedEntryLockRecordExecuteParams, + ILockingMechanismGetLockedEntryLockRecordExecuteResult +} from "~/domain/abstractions/ILockingMechanismGetLockedEntryLockRecord"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismGetLockedEntryLockRecord + implements ILockingMechanismGetLockedEntryLockRecord +{ + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + public async execute( + params: ILockingMechanismGetLockedEntryLockRecordExecuteParams + ): Promise<ILockingMechanismGetLockedEntryLockRecordExecuteResult> { + const result = await this.client.query< + ILockingMechanismGetLockedEntryLockRecordResponse, + ILockingMechanismGetLockedEntryLockRecordVariables + >({ + query: GET_LOCKED_ENTRY_LOCK_RECORD_QUERY, + variables: params + }); + if (result.data.lockingMechanism.getLockedEntryLockRecord.error) { + throw new WebinyError(result.data.lockingMechanism.getLockedEntryLockRecord.error); + } + return result.data.lockingMechanism.getLockedEntryLockRecord; + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismIsEntryLocked.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismIsEntryLocked.ts new file mode 100644 index 00000000000..bda3937999a --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismIsEntryLocked.ts @@ -0,0 +1,39 @@ +import { + ILockingMechanismIsEntryLocked, + ILockingMechanismIsEntryLockedParams, + ILockingMechanismIsEntryLockedResult +} from "~/domain/abstractions/ILockingMechanismIsEntryLocked"; +import { ILockingMechanismClient } from "./abstractions/ILockingMechanismClient"; +import { + ILockingMechanismIsEntryLockedResponse, + ILockingMechanismIsEntryLockedVariables, + IS_ENTRY_LOCKED_QUERY +} from "~/domain/graphql/isEntryLocked"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismIsEntryLocked implements ILockingMechanismIsEntryLocked { + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + public async execute( + params: ILockingMechanismIsEntryLockedParams + ): Promise<ILockingMechanismIsEntryLockedResult> { + try { + const result = await this.client.query< + ILockingMechanismIsEntryLockedResponse, + ILockingMechanismIsEntryLockedVariables + >({ + query: IS_ENTRY_LOCKED_QUERY, + variables: params + }); + return !!result.data.lockingMechanism.isEntryLocked.data; + } catch { + return false; + } + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismListLockRecords.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismListLockRecords.ts new file mode 100644 index 00000000000..b1f1dce601b --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismListLockRecords.ts @@ -0,0 +1,48 @@ +import { WebinyError } from "@webiny/error"; +import { ApolloClient } from "apollo-client"; +import { + ILockingMechanismListLockRecords, + ILockingMechanismListLockRecordsParams, + ILockingMechanismListLockRecordsResult +} from "./abstractions/ILockingMechanismListLockRecords"; +import { ILockingMechanismClient } from "./abstractions/ILockingMechanismClient"; +import { createLockingMechanismClient } from "./utils/createLockingMechanismClient"; +import { + ILockingMechanismListLockedRecordsResponse, + ILockingMechanismListLockedRecordsVariables, + LIST_LOCK_RECORDS +} from "~/domain/graphql/listLockRecords"; + +interface Params { + client: ILockingMechanismClient | ApolloClient<any>; +} + +export class LockingMechanismListLockRecords implements ILockingMechanismListLockRecords { + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = createLockingMechanismClient(params.client); + } + public async execute( + params: ILockingMechanismListLockRecordsParams + ): Promise<ILockingMechanismListLockRecordsResult> { + const { where, sort, limit, after } = params; + + const result = await this.client.query< + ILockingMechanismListLockedRecordsResponse, + ILockingMechanismListLockedRecordsVariables + >({ + query: LIST_LOCK_RECORDS, + variables: { + where, + sort, + limit, + after + } + }); + if (!result.data?.lockingMechanism?.listLockRecords) { + throw new WebinyError("No data returned from server."); + } + return result.data.lockingMechanism.listLockRecords; + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismLockEntry.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismLockEntry.ts new file mode 100644 index 00000000000..4244797e6b0 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismLockEntry.ts @@ -0,0 +1,26 @@ +import { WebinyError } from "@webiny/error"; +import { + ILockingMechanismLockEntry, + ILockingMechanismLockEntryParams, + ILockingMechanismLockEntryResult +} from "~/domain/abstractions/ILockingMechanismLockEntry"; +import { ILockingMechanismClient } from "./abstractions/ILockingMechanismClient"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismLockEntry implements ILockingMechanismLockEntry { + // eslint-disable-next-line + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + public async execute( + // eslint-disable-next-line + params: ILockingMechanismLockEntryParams + ): Promise<ILockingMechanismLockEntryResult> { + throw new WebinyError("Method not implemented."); + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntry.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntry.ts new file mode 100644 index 00000000000..001ca485e01 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntry.ts @@ -0,0 +1,39 @@ +import { WebinyError } from "@webiny/error"; +import { + ILockingMechanismUnlockEntry, + ILockingMechanismUnlockEntryParams, + ILockingMechanismUnlockEntryResult +} from "~/domain/abstractions/ILockingMechanismUnlockEntry"; +import { ILockingMechanismClient } from "./abstractions/ILockingMechanismClient"; +import { + ILockingMechanismUnlockEntryResponse, + ILockingMechanismUnlockEntryVariables, + UNLOCK_ENTRY_MUTATION +} from "~/domain/graphql/unlockEntry"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismUnlockEntry implements ILockingMechanismUnlockEntry { + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + public async execute( + params: ILockingMechanismUnlockEntryParams + ): Promise<ILockingMechanismUnlockEntryResult> { + const result = await this.client.mutation< + ILockingMechanismUnlockEntryResponse, + ILockingMechanismUnlockEntryVariables + >({ + mutation: UNLOCK_ENTRY_MUTATION, + variables: params + }); + if (!result.data?.lockingMechanism?.unlockEntry) { + throw new WebinyError("No data returned from server."); + } + return result.data.lockingMechanism.unlockEntry; + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntryRequest.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntryRequest.ts new file mode 100644 index 00000000000..212e0814862 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismUnlockEntryRequest.ts @@ -0,0 +1,26 @@ +import { WebinyError } from "@webiny/error"; +import { + ILockingMechanismUnlockEntryRequest, + ILockingMechanismUnlockEntryRequestParams, + ILockingMechanismUnlockEntryRequestResult +} from "~/domain/abstractions/ILockingMechanismUnlockEntryRequest"; +import { ILockingMechanismClient } from "./abstractions/ILockingMechanismClient"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismUnlockEntryRequest implements ILockingMechanismUnlockEntryRequest { + // @eslint-disable-next-line + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + public async execute( + // eslint-disable-next-line + params: ILockingMechanismUnlockEntryRequestParams + ): Promise<ILockingMechanismUnlockEntryRequestResult> { + throw new WebinyError("Method not implemented."); + } +} diff --git a/packages/app-locking-mechanism/src/domain/LockingMechanismUpdateEntryLock.ts b/packages/app-locking-mechanism/src/domain/LockingMechanismUpdateEntryLock.ts new file mode 100644 index 00000000000..4f20aa56b70 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/LockingMechanismUpdateEntryLock.ts @@ -0,0 +1,40 @@ +import { WebinyError } from "@webiny/error"; +import { + ILockingMechanismUpdateEntryLock, + ILockingMechanismUpdateEntryLockExecuteParams, + ILockingMechanismUpdateEntryLockExecuteResult +} from "~/domain/abstractions/ILockingMechanismUpdateEntryLock"; +import { ILockingMechanismClient } from "~/domain/abstractions/ILockingMechanismClient"; +import { + ILockingMechanismUpdateEntryLockResponse, + ILockingMechanismUpdateEntryLockVariables, + UPDATE_ENTRY_LOCK +} from "~/domain/graphql/updateEntryLock"; + +interface Params { + client: ILockingMechanismClient; +} + +export class LockingMechanismUpdateEntryLock implements ILockingMechanismUpdateEntryLock { + private readonly client: ILockingMechanismClient; + + public constructor(params: Params) { + this.client = params.client; + } + + public async execute( + params: ILockingMechanismUpdateEntryLockExecuteParams + ): Promise<ILockingMechanismUpdateEntryLockExecuteResult> { + const result = await this.client.mutation< + ILockingMechanismUpdateEntryLockResponse, + ILockingMechanismUpdateEntryLockVariables + >({ + mutation: UPDATE_ENTRY_LOCK, + variables: params + }); + if (!result.data?.lockingMechanism?.updateEntryLock) { + throw new WebinyError("No data returned from server."); + } + return result.data.lockingMechanism.updateEntryLock; + } +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanism.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanism.ts new file mode 100644 index 00000000000..fc9afc8d99a --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanism.ts @@ -0,0 +1,62 @@ +import { + IIsRecordLockedParams, + IUpdateEntryLockParams, + ILockingMechanismRecord, + IPossiblyLockingMechanismRecord, + ILockingMechanismError, + ILockingMechanismLockRecord, + IUnlockEntryParams, + IFetchLockRecordParams, + IFetchLockRecordResult, + IFetchLockedEntryLockRecordParams +} from "~/types"; +import { ILockingMechanismUnlockEntryResult } from "./ILockingMechanismUnlockEntry"; + +export interface ILockingMechanismUpdateEntryLockResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanism< + T extends IPossiblyLockingMechanismRecord = IPossiblyLockingMechanismRecord +> { + loading: boolean; + records: ILockingMechanismRecord[]; + setRecords( + folderId: string, + type: string, + records: T[] + ): Promise<ILockingMechanismRecord[] | undefined>; + isLockExpired(input: Date | string): boolean; + isRecordLocked(record: IIsRecordLockedParams): boolean; + getLockRecordEntry(id: string): ILockingMechanismRecord | undefined; + fetchLockRecord(params: IFetchLockRecordParams): Promise<IFetchLockRecordResult>; + fetchLockedEntryLockRecord( + params: IFetchLockedEntryLockRecordParams + ): Promise<ILockingMechanismLockRecord | null>; + updateEntryLock( + params: IUpdateEntryLockParams + ): Promise<ILockingMechanismUpdateEntryLockResult>; + removeEntryLock(params: IUnlockEntryParams): void; + unlockEntry( + params: IUnlockEntryParams, + force?: boolean + ): Promise<ILockingMechanismUnlockEntryResult>; + // lockEntry(params: ILockingMechanismLockEntryParams): Promise<ILockingMechanismLockEntryResult>; + // unlockEntryRequest( + // params: ILockingMechanismUnlockEntryRequestParams + // ): Promise<ILockingMechanismUnlockEntryRequestResult>; + // isEntryLocked( + // params: ILockingMechanismIsEntryLockedParams + // ): Promise<ILockingMechanismIsEntryLockedResult>; + // getLockRecord( + // params: ILockingMechanismGetLockRecordParams + // ): Promise<ILockingMechanismGetLockRecordResult>; + // listLockRecords( + // params: ILockingMechanismListLockRecordsParams + // ): Promise<ILockingMechanismListLockRecordsResult>; + + // onRequestAccess(): Promise<void>; + // acceptAccessRequest(): Promise<void>; + // rejectAccessRequest(): Promise<void>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismClient.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismClient.ts new file mode 100644 index 00000000000..6dbefbfa229 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismClient.ts @@ -0,0 +1,7 @@ +import { ApolloQueryResult, QueryOptions, MutationOptions } from "apollo-client"; +import { FetchResult } from "apollo-link"; + +export interface ILockingMechanismClient { + query<T, R>(params: QueryOptions<R>): Promise<ApolloQueryResult<T>>; + mutation<T, R>(options: MutationOptions<T, R>): Promise<FetchResult<T>>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockRecord.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockRecord.ts new file mode 100644 index 00000000000..00696741623 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockRecord.ts @@ -0,0 +1,17 @@ +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; + +export interface ILockingMechanismGetLockRecordExecuteParams { + id: string; + type: string; +} + +export interface ILockingMechanismGetLockRecordExecuteResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismGetLockRecord { + execute( + params: ILockingMechanismGetLockRecordExecuteParams + ): Promise<ILockingMechanismGetLockRecordExecuteResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockedEntryLockRecord.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockedEntryLockRecord.ts new file mode 100644 index 00000000000..b000503bfb7 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismGetLockedEntryLockRecord.ts @@ -0,0 +1,17 @@ +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; + +export interface ILockingMechanismGetLockedEntryLockRecordExecuteParams { + id: string; + type: string; +} + +export interface ILockingMechanismGetLockedEntryLockRecordExecuteResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismGetLockedEntryLockRecord { + execute( + params: ILockingMechanismGetLockedEntryLockRecordExecuteParams + ): Promise<ILockingMechanismGetLockedEntryLockRecordExecuteResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismIsEntryLocked.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismIsEntryLocked.ts new file mode 100644 index 00000000000..98acea42caf --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismIsEntryLocked.ts @@ -0,0 +1,12 @@ +export interface ILockingMechanismIsEntryLockedParams { + id: string; + type: string; +} + +export type ILockingMechanismIsEntryLockedResult = boolean; + +export interface ILockingMechanismIsEntryLocked { + execute( + params: ILockingMechanismIsEntryLockedParams + ): Promise<ILockingMechanismIsEntryLockedResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismListLockRecords.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismListLockRecords.ts new file mode 100644 index 00000000000..a5a8d03d386 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismListLockRecords.ts @@ -0,0 +1,29 @@ +import { + ILockingMechanismError, + ILockingMechanismLockRecord, + ILockingMechanismMeta +} from "~/types"; + +export interface ILockingMechanismListLockRecordsParamsWhere { + id_in?: string[]; + type?: string; +} + +export interface ILockingMechanismListLockRecordsParams { + where?: ILockingMechanismListLockRecordsParamsWhere; + sort?: string[]; + limit?: number; + after?: string; +} + +export interface ILockingMechanismListLockRecordsResult { + data: ILockingMechanismLockRecord[] | null; + error: ILockingMechanismError | null; + meta: ILockingMechanismMeta | null; +} + +export interface ILockingMechanismListLockRecords { + execute( + params: ILockingMechanismListLockRecordsParams + ): Promise<ILockingMechanismListLockRecordsResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismLockEntry.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismLockEntry.ts new file mode 100644 index 00000000000..bc0de457f20 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismLockEntry.ts @@ -0,0 +1,15 @@ +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; + +export interface ILockingMechanismLockEntryParams { + id: string; + type: string; +} + +export interface ILockingMechanismLockEntryResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismLockEntry { + execute(params: ILockingMechanismLockEntryParams): Promise<ILockingMechanismLockEntryResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntry.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntry.ts new file mode 100644 index 00000000000..9cff3c9f38c --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntry.ts @@ -0,0 +1,18 @@ +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; + +export interface ILockingMechanismUnlockEntryParams { + id: string; + type: string; + force?: boolean; +} + +export interface ILockingMechanismUnlockEntryResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismUnlockEntry { + execute( + params: ILockingMechanismUnlockEntryParams + ): Promise<ILockingMechanismUnlockEntryResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntryRequest.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntryRequest.ts new file mode 100644 index 00000000000..139757e1c57 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUnlockEntryRequest.ts @@ -0,0 +1,17 @@ +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; + +export interface ILockingMechanismUnlockEntryRequestParams { + id: string; + type: string; +} + +export interface ILockingMechanismUnlockEntryRequestResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismUnlockEntryRequest { + execute( + params: ILockingMechanismUnlockEntryRequestParams + ): Promise<ILockingMechanismUnlockEntryRequestResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUpdateEntryLock.ts b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUpdateEntryLock.ts new file mode 100644 index 00000000000..b25c60df962 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/abstractions/ILockingMechanismUpdateEntryLock.ts @@ -0,0 +1,16 @@ +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; + +export interface ILockingMechanismUpdateEntryLockExecuteParams { + id: string; + type: string; +} +export interface ILockingMechanismUpdateEntryLockExecuteResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismUpdateEntryLock { + execute( + params: ILockingMechanismUpdateEntryLockExecuteParams + ): Promise<ILockingMechanismUpdateEntryLockExecuteResult>; +} diff --git a/packages/app-locking-mechanism/src/domain/graphql/fields.ts b/packages/app-locking-mechanism/src/domain/graphql/fields.ts new file mode 100644 index 00000000000..66a3fc58bb4 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/fields.ts @@ -0,0 +1,29 @@ +export const LOCK_RECORD_FIELDS = /* GraphQL */ ` + id + lockedBy { + id + displayName + type + } + lockedOn + updatedOn + expiresOn + targetId + type + actions { + type + message + createdBy { + id + displayName + type + } + createdOn + } +`; + +export const ERROR_FIELDS = /* GraphQL */ ` + message + code + data +`; diff --git a/packages/app-locking-mechanism/src/domain/graphql/getLockRecord.ts b/packages/app-locking-mechanism/src/domain/graphql/getLockRecord.ts new file mode 100644 index 00000000000..e665e66df52 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/getLockRecord.ts @@ -0,0 +1,30 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "./fields"; +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; +import { ILockingMechanismGetLockRecordExecuteParams } from "~/domain/abstractions/ILockingMechanismGetLockRecord"; + +export type ILockingMechanismGetLockRecordVariables = ILockingMechanismGetLockRecordExecuteParams; + +export interface ILockingMechanismGetLockRecordResponse { + lockingMechanism: { + getLockRecord: { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const GET_LOCK_RECORD_QUERY = gql` + query LockingMechanismGetLockRecord($id: ID!) { + lockingMechanism { + getLockRecord(id: $id) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } +`; diff --git a/packages/app-locking-mechanism/src/domain/graphql/getLockedEntryLockRecord.ts b/packages/app-locking-mechanism/src/domain/graphql/getLockedEntryLockRecord.ts new file mode 100644 index 00000000000..bd63be8dbea --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/getLockedEntryLockRecord.ts @@ -0,0 +1,31 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "./fields"; +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; +import { ILockingMechanismGetLockedEntryLockRecordExecuteParams } from "~/domain/abstractions/ILockingMechanismGetLockedEntryLockRecord"; + +export type ILockingMechanismGetLockedEntryLockRecordVariables = + ILockingMechanismGetLockedEntryLockRecordExecuteParams; + +export interface ILockingMechanismGetLockedEntryLockRecordResponse { + lockingMechanism: { + getLockedEntryLockRecord: { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const GET_LOCKED_ENTRY_LOCK_RECORD_QUERY = gql` + query LockingMechanismGetLockedEntryLockRecord($id: ID!, $type: String!) { + lockingMechanism { + getLockedEntryLockRecord(id: $id, type: $type) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } +`; diff --git a/packages/app-locking-mechanism/src/domain/graphql/isEntryLocked.ts b/packages/app-locking-mechanism/src/domain/graphql/isEntryLocked.ts new file mode 100644 index 00000000000..e8a805ceef9 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/isEntryLocked.ts @@ -0,0 +1,28 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS } from "~/domain/graphql/fields"; +import { ILockingMechanismError } from "~/types"; +import { ILockingMechanismIsEntryLockedParams } from "../abstractions/ILockingMechanismIsEntryLocked"; + +export type ILockingMechanismIsEntryLockedVariables = ILockingMechanismIsEntryLockedParams; + +export interface ILockingMechanismIsEntryLockedResponse { + lockingMechanism: { + isEntryLocked: { + data: boolean | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const IS_ENTRY_LOCKED_QUERY = gql` + query LockingMechanismIsEntryLocked($id: ID!, $type: String!) { + lockingMechanism { + isEntryLocked(id: $id, type: $type) { + data + error { + ${ERROR_FIELDS} + } + } + } + } +`; diff --git a/packages/app-locking-mechanism/src/domain/graphql/listLockRecords.ts b/packages/app-locking-mechanism/src/domain/graphql/listLockRecords.ts new file mode 100644 index 00000000000..f88c1b74f07 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/listLockRecords.ts @@ -0,0 +1,48 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "~/domain/graphql/fields"; +import { + ILockingMechanismError, + ILockingMechanismLockRecord, + ILockingMechanismMeta +} from "~/types"; +import { ILockingMechanismListLockRecordsParams } from "~/domain/abstractions/ILockingMechanismListLockRecords"; + +export interface ILockingMechanismListLockedRecordsVariablesWhere { + id_in?: string[]; +} + +export type ILockingMechanismListLockedRecordsVariables = ILockingMechanismListLockRecordsParams; + +export interface ILockingMechanismListLockedRecordsResponse { + lockingMechanism: { + listLockRecords: { + data: ILockingMechanismLockRecord[] | null; + error: ILockingMechanismError | null; + meta: ILockingMechanismMeta | null; + }; + }; +} + +export const createListLockRecords = () => { + return gql` + query LockingMechanismListLockedRecords( + $where: LockingMechanismListWhereInput + $sort: [LockingMechanismListSorter!] + $limit: Int + $after: String + ) { + lockingMechanism { + listLockRecords(where: $where, sort: $sort, limit: $limit, after: $after) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } + `; +}; + +export const LIST_LOCK_RECORDS = createListLockRecords(); diff --git a/packages/app-locking-mechanism/src/domain/graphql/lockEntry.ts b/packages/app-locking-mechanism/src/domain/graphql/lockEntry.ts new file mode 100644 index 00000000000..225c5fceba9 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/lockEntry.ts @@ -0,0 +1,32 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "./fields"; +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; +import { ILockingMechanismLockEntryParams } from "~/domain/abstractions/ILockingMechanismLockEntry"; + +export type ILockingMechanismLockEntryVariables = ILockingMechanismLockEntryParams; + +export interface ILockingMechanismLockEntryResponse { + lockingMechanism: { + lockEntry: { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const createLockGraphQL = () => { + return gql` + mutation LockingMechanismLockEntry($id: ID!, $type: String!) { + lockingMechanism { + lockEntry(id: $id, type: $type) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } + `; +}; diff --git a/packages/app-locking-mechanism/src/domain/graphql/unlockEntry.ts b/packages/app-locking-mechanism/src/domain/graphql/unlockEntry.ts new file mode 100644 index 00000000000..0871c67e903 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/unlockEntry.ts @@ -0,0 +1,30 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "./fields"; +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; +import { ILockingMechanismUnlockEntryParams } from "../abstractions/ILockingMechanismUnlockEntry"; + +export type ILockingMechanismUnlockEntryVariables = ILockingMechanismUnlockEntryParams; + +export interface ILockingMechanismUnlockEntryResponse { + lockingMechanism: { + unlockEntry: { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const UNLOCK_ENTRY_MUTATION = gql` + mutation LockingMechanismUnlockEntry($id: ID!, $type: String!, $force: Boolean) { + lockingMechanism { + unlockEntry(id: $id, type: $type, force: $force) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } +`; diff --git a/packages/app-locking-mechanism/src/domain/graphql/unlockEntryRequest.ts b/packages/app-locking-mechanism/src/domain/graphql/unlockEntryRequest.ts new file mode 100644 index 00000000000..70fde29a546 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/unlockEntryRequest.ts @@ -0,0 +1,33 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "./fields"; +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; +import { ILockingMechanismUnlockEntryRequestParams } from "../abstractions/ILockingMechanismUnlockEntryRequest"; + +export type ILockingMechanismUnlockEntryRequestVariables = + ILockingMechanismUnlockEntryRequestParams; + +export interface ILockingMechanismUnlockEntryRequestResponse { + lockingMechanism: { + unlockEntryRequest: { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const createUnlockEntryRequestGraphQL = () => { + return gql` + mutation LockingMechanismUnlockEntryRequest($id: ID!, $type: String!) { + lockingMechanism { + unlockEntryRequest(id: $id, type: $type) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } + `; +}; diff --git a/packages/app-locking-mechanism/src/domain/graphql/updateEntryLock.ts b/packages/app-locking-mechanism/src/domain/graphql/updateEntryLock.ts new file mode 100644 index 00000000000..75c3f93c28f --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/graphql/updateEntryLock.ts @@ -0,0 +1,31 @@ +import gql from "graphql-tag"; +import { ERROR_FIELDS, LOCK_RECORD_FIELDS } from "./fields"; +import { ILockingMechanismError, ILockingMechanismLockRecord } from "~/types"; +import { ILockingMechanismUpdateEntryLockExecuteParams } from "~/domain/abstractions/ILockingMechanismUpdateEntryLock"; + +export type ILockingMechanismUpdateEntryLockVariables = + ILockingMechanismUpdateEntryLockExecuteParams; + +export interface ILockingMechanismUpdateEntryLockResponse { + lockingMechanism: { + updateEntryLock: { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; + }; + }; +} + +export const UPDATE_ENTRY_LOCK = gql` + mutation LockingMechanismUpdateEntryLock($id: ID!, $type: String!) { + lockingMechanism { + updateEntryLock(id: $id, type: $type) { + data { + ${LOCK_RECORD_FIELDS} + } + error { + ${ERROR_FIELDS} + } + } + } + } +`; diff --git a/packages/app-locking-mechanism/src/domain/utils/createLockingMechanismClient.ts b/packages/app-locking-mechanism/src/domain/utils/createLockingMechanismClient.ts new file mode 100644 index 00000000000..8f0f1b12c2c --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/utils/createLockingMechanismClient.ts @@ -0,0 +1,12 @@ +import { ILockingMechanismClient } from "~/domain/abstractions/ILockingMechanismClient"; +import { LockingMechanismClient } from "~/domain/LockingMechanismClient"; +import { ApolloClient } from "apollo-client"; + +export const createLockingMechanismClient = ( + client: ILockingMechanismClient | ApolloClient<any> +) => { + if (client instanceof ApolloClient) { + return new LockingMechanismClient({ client }); + } + return client; +}; diff --git a/packages/app-locking-mechanism/src/domain/utils/createLockingMechanismError.ts b/packages/app-locking-mechanism/src/domain/utils/createLockingMechanismError.ts new file mode 100644 index 00000000000..4f12c556d18 --- /dev/null +++ b/packages/app-locking-mechanism/src/domain/utils/createLockingMechanismError.ts @@ -0,0 +1,23 @@ +import { ILockingMechanismError } from "~/types"; + +export interface IError extends Error { + code?: string; + data?: any; +} + +export const createLockingMechanismError = ( + error: IError | ILockingMechanismError +): ILockingMechanismError => { + if (error instanceof Error) { + return { + message: error.message, + code: error.code || "UNKNOWN_ERROR", + data: error.data + }; + } + return { + message: error.message, + code: error.code, + data: error.data + }; +}; diff --git a/packages/app-locking-mechanism/src/hooks/index.ts b/packages/app-locking-mechanism/src/hooks/index.ts new file mode 100644 index 00000000000..a0a8d7b8055 --- /dev/null +++ b/packages/app-locking-mechanism/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useLockingMechanism"; +export * from "./usePermission"; diff --git a/packages/app-locking-mechanism/src/hooks/useLockingMechanism.ts b/packages/app-locking-mechanism/src/hooks/useLockingMechanism.ts new file mode 100644 index 00000000000..b33cb202e84 --- /dev/null +++ b/packages/app-locking-mechanism/src/hooks/useLockingMechanism.ts @@ -0,0 +1,14 @@ +import { WebinyError } from "@webiny/error"; +import { useContext } from "react"; +import { LockingMechanismContext } from "~/components/LockingMechanismProvider"; +import { ILockingMechanismContext, IPossiblyLockingMechanismRecord } from "~/types"; + +export const useLockingMechanism = < + T extends IPossiblyLockingMechanismRecord = IPossiblyLockingMechanismRecord +>() => { + const context = useContext(LockingMechanismContext); + if (!context) { + throw new WebinyError("useLockingMechanism must be used within a LockingMechanismProvider"); + } + return context as ILockingMechanismContext<T>; +}; diff --git a/packages/app-locking-mechanism/src/hooks/usePermission.ts b/packages/app-locking-mechanism/src/hooks/usePermission.ts new file mode 100644 index 00000000000..af5dec8d332 --- /dev/null +++ b/packages/app-locking-mechanism/src/hooks/usePermission.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; +import { useSecurity } from "@webiny/app-security"; + +export const usePermission = () => { + const { identity, getPermission } = useSecurity(); + + const hasFullAccess = useMemo(() => { + return !!getPermission("lockingMechanism.*"); + }, [identity]); + + return { + hasFullAccess + }; +}; diff --git a/packages/app-locking-mechanism/src/index.tsx b/packages/app-locking-mechanism/src/index.tsx new file mode 100644 index 00000000000..849de02d8a1 --- /dev/null +++ b/packages/app-locking-mechanism/src/index.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Provider } from "@webiny/app"; +import { LockingMechanismProvider as LockingMechanismProviderComponent } from "~/components/LockingMechanismProvider"; +import { HeadlessCmsActionsAcoCell } from "~/components/HeadlessCmsActionsAcoCell"; +import { HeadlessCmsContentEntry } from "~/components/HeadlessCmsContentEntry"; +import { useWcp } from "@webiny/app-wcp"; + +export * from "~/components/LockingMechanismProvider"; +export * from "~/hooks"; + +export interface LockingMechanismProviderProps { + children: React.ReactNode; +} + +const LockingMechanismHoc = (Component: React.ComponentType) => { + return function LockingMechanismProvider({ children }: LockingMechanismProviderProps) { + const { canUseRecordLocking } = useWcp(); + if (!canUseRecordLocking()) { + return <Component>{children}</Component>; + } + return ( + <Component> + <LockingMechanismProviderComponent> + {children} + <HeadlessCmsActionsAcoCell /> + <HeadlessCmsContentEntry /> + </LockingMechanismProviderComponent> + </Component> + ); + }; +}; + +const LockingMechanismExtension = () => { + return ( + <> + <Provider hoc={LockingMechanismHoc} /> + </> + ); +}; + +export const LockingMechanism = React.memo(LockingMechanismExtension); diff --git a/packages/app-locking-mechanism/src/types.ts b/packages/app-locking-mechanism/src/types.ts new file mode 100644 index 00000000000..5aa8b94f480 --- /dev/null +++ b/packages/app-locking-mechanism/src/types.ts @@ -0,0 +1,100 @@ +import { EntryTableItem } from "@webiny/app-headless-cms/types"; +import { GenericRecord } from "@webiny/app/types"; +import { ILockingMechanismUnlockEntryResult } from "~/domain/abstractions/ILockingMechanismUnlockEntry"; + +// export interface ILockingMechanismContextRecord { +// id: string; +// type: string; +// locked?: boolean; +// } + +export interface ILockingMechanismIdentity { + id: string; + displayName: string; + type: string; +} + +export interface ILockingMechanismRecordLocked { + lockedBy: ILockingMechanismIdentity; + lockedOn: string; + expiresOn: string; + actions: ILockingMechanismLockRecordAction[]; +} + +export interface IPossiblyLockingMechanismRecord extends EntryTableItem { + $lockingType?: string; + entryId: string; + $locked?: ILockingMechanismRecordLocked | null; +} + +export interface ILockingMechanismRecord extends IPossiblyLockingMechanismRecord { + entryId: string; + $lockingType: string; +} + +export type IIsRecordLockedParams = Pick<ILockingMechanismRecord, "id" | "$lockingType">; + +export type IUpdateEntryLockParams = Pick<ILockingMechanismRecord, "id" | "$lockingType">; + +export type IUnlockEntryParams = Pick<ILockingMechanismRecord, "id" | "$lockingType">; + +export type IFetchLockRecordParams = Pick<ILockingMechanismRecord, "id" | "$lockingType">; + +export type IFetchLockedEntryLockRecordParams = Pick< + ILockingMechanismRecord, + "id" | "$lockingType" +>; + +export interface IFetchLockRecordResult { + data: ILockingMechanismLockRecord | null; + error: ILockingMechanismError | null; +} + +export interface ILockingMechanismContext< + T extends IPossiblyLockingMechanismRecord = IPossiblyLockingMechanismRecord +> { + readonly loading: boolean; + readonly records: IPossiblyLockingMechanismRecord[]; + readonly error?: ILockingMechanismError | null; + setRecords(folderId: string, type: string, records: T[]): Promise<void>; + updateEntryLock(params: IUpdateEntryLockParams): Promise<void>; + isRecordLocked(params?: IIsRecordLockedParams): boolean; + getLockRecordEntry(id: string): ILockingMechanismRecord | undefined; + fetchLockRecord(params: IFetchLockRecordParams): Promise<IFetchLockRecordResult>; + fetchLockedEntryLockRecord( + params: IFetchLockedEntryLockRecordParams + ): Promise<ILockingMechanismLockRecord | null>; + unlockEntry(params: IUnlockEntryParams): Promise<ILockingMechanismUnlockEntryResult>; + removeEntryLock(params: IUnlockEntryParams): void; + unlockEntryForce(params: IUnlockEntryParams): Promise<ILockingMechanismUnlockEntryResult>; + isLockExpired(input: Date | string): boolean; +} + +export interface ILockingMechanismLockRecordAction { + type: string; + message: string; + createdBy: ILockingMechanismIdentity; + createdOn: string; +} + +export interface ILockingMechanismLockRecord { + id: string; + lockedOn: string; + expiresOn: string; + lockedBy: ILockingMechanismIdentity; + targetId: string; + type: string; + actions: ILockingMechanismLockRecordAction[]; +} + +export interface ILockingMechanismMeta { + totalCount: number; + cursor: string | null; + hasMoreItems: boolean; +} + +export interface ILockingMechanismError<T = GenericRecord> { + message: string; + code: string; + data?: T; +} diff --git a/packages/app-locking-mechanism/src/utils/createCacheKey.ts b/packages/app-locking-mechanism/src/utils/createCacheKey.ts new file mode 100644 index 00000000000..0875502afa4 --- /dev/null +++ b/packages/app-locking-mechanism/src/utils/createCacheKey.ts @@ -0,0 +1,19 @@ +import { GenericRecord } from "@webiny/app/types"; +import { sha1 } from "crypto-hash"; + +export type ICreateCacheKeyInput = string | GenericRecord | ICreateCacheKeyInput[]; + +const createKey = (input: ICreateCacheKeyInput): string => { + if (typeof input === "string") { + return input; + } + return JSON.stringify(input); +}; + +export const createCacheKey = (input: ICreateCacheKeyInput): Promise<string> => { + const key = createKey(input); + + return sha1(key, { + outputFormat: "hex" + }); +}; diff --git a/packages/app-locking-mechanism/tsconfig.build.json b/packages/app-locking-mechanism/tsconfig.build.json new file mode 100644 index 00000000000..aa3e1db292e --- /dev/null +++ b/packages/app-locking-mechanism/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../app/tsconfig.build.json" }, + { "path": "../app-admin/tsconfig.build.json" }, + { "path": "../app-headless-cms/tsconfig.build.json" }, + { "path": "../app-security/tsconfig.build.json" }, + { "path": "../app-wcp/tsconfig.build.json" }, + { "path": "../app-websockets/tsconfig.build.json" }, + { "path": "../error/tsconfig.build.json" }, + { "path": "../react-router/tsconfig.build.json" }, + { "path": "../ui/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/app-locking-mechanism/tsconfig.json b/packages/app-locking-mechanism/tsconfig.json new file mode 100644 index 00000000000..dc7c711a045 --- /dev/null +++ b/packages/app-locking-mechanism/tsconfig.json @@ -0,0 +1,46 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../app" }, + { "path": "../app-admin" }, + { "path": "../app-headless-cms" }, + { "path": "../app-security" }, + { "path": "../app-wcp" }, + { "path": "../app-websockets" }, + { "path": "../error" }, + { "path": "../react-router" }, + { "path": "../ui" }, + { "path": "../utils" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/app/*": ["../app/src/*"], + "@webiny/app": ["../app/src"], + "@webiny/app-admin/*": ["../app-admin/src/*"], + "@webiny/app-admin": ["../app-admin/src"], + "@webiny/app-headless-cms/*": ["../app-headless-cms/src/*"], + "@webiny/app-headless-cms": ["../app-headless-cms/src"], + "@webiny/app-security/*": ["../app-security/src/*"], + "@webiny/app-security": ["../app-security/src"], + "@webiny/app-wcp/*": ["../app-wcp/src/*"], + "@webiny/app-wcp": ["../app-wcp/src"], + "@webiny/app-websockets/*": ["../app-websockets/src/*"], + "@webiny/app-websockets": ["../app-websockets/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/react-router/*": ["../react-router/src/*"], + "@webiny/react-router": ["../react-router/src"], + "@webiny/ui/*": ["../ui/src/*"], + "@webiny/ui": ["../ui/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/app-locking-mechanism/webiny.config.js b/packages/app-locking-mechanism/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/app-locking-mechanism/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/app-security-access-management/package.json b/packages/app-security-access-management/package.json index 66891b20aa4..d8190d1ae0a 100644 --- a/packages/app-security-access-management/package.json +++ b/packages/app-security-access-management/package.json @@ -15,7 +15,7 @@ "dependencies": { "@apollo/react-hooks": "^3.1.5", "@emotion/styled": "^11.10.6", - "@material-design-icons/svg": "^0.12.1", + "@material-design-icons/svg": "^0.14.2", "@webiny/app": "0.0.0", "@webiny/app-admin": "0.0.0", "@webiny/app-security": "0.0.0", diff --git a/packages/app-serverless-cms/package.json b/packages/app-serverless-cms/package.json index 22a11655946..e58af42ed96 100644 --- a/packages/app-serverless-cms/package.json +++ b/packages/app-serverless-cms/package.json @@ -24,6 +24,7 @@ "@webiny/app-headless-cms": "0.0.0", "@webiny/app-i18n": "0.0.0", "@webiny/app-i18n-content": "0.0.0", + "@webiny/app-locking-mechanism": "0.0.0", "@webiny/app-mailer": "0.0.0", "@webiny/app-page-builder": "0.0.0", "@webiny/app-security": "0.0.0", diff --git a/packages/app-serverless-cms/src/Admin.tsx b/packages/app-serverless-cms/src/Admin.tsx index 8cbea87c4c5..7609b75d93d 100644 --- a/packages/app-serverless-cms/src/Admin.tsx +++ b/packages/app-serverless-cms/src/Admin.tsx @@ -30,6 +30,7 @@ import { LexicalEditorActions } from "@webiny/lexical-editor-actions"; import { Module as MailerSettings } from "@webiny/app-mailer"; import { Folders } from "@webiny/app-aco"; import { Websockets } from "@webiny/app-websockets"; +import { LockingMechanism } from "@webiny/app-locking-mechanism"; export interface AdminProps extends Omit<BaseAdminProps, "createApolloClient"> { createApolloClient?: BaseAdminProps["createApolloClient"]; @@ -54,8 +55,9 @@ const App = (props: AdminProps) => { <GraphQLPlayground createApolloClient={createApolloClient} /> <I18N /> <I18NContent /> - <Websockets /> <Provider hoc={ViewCompositionProvider} /> + <Websockets /> + <LockingMechanism /> <PageBuilder /> <LexicalEditorPlugin /> <LexicalEditorActions /> diff --git a/packages/app-serverless-cms/tsconfig.build.json b/packages/app-serverless-cms/tsconfig.build.json index 9bbaf9bc7bb..71d0eaea0f6 100644 --- a/packages/app-serverless-cms/tsconfig.build.json +++ b/packages/app-serverless-cms/tsconfig.build.json @@ -15,6 +15,7 @@ { "path": "../app-headless-cms/tsconfig.build.json" }, { "path": "../app-i18n/tsconfig.build.json" }, { "path": "../app-i18n-content/tsconfig.build.json" }, + { "path": "../app-locking-mechanism/tsconfig.build.json" }, { "path": "../app-mailer/tsconfig.build.json" }, { "path": "../app-page-builder/tsconfig.build.json" }, { "path": "../app-security/tsconfig.build.json" }, diff --git a/packages/app-serverless-cms/tsconfig.json b/packages/app-serverless-cms/tsconfig.json index 5526e481696..3bd99dee6f1 100644 --- a/packages/app-serverless-cms/tsconfig.json +++ b/packages/app-serverless-cms/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../app-headless-cms" }, { "path": "../app-i18n" }, { "path": "../app-i18n-content" }, + { "path": "../app-locking-mechanism" }, { "path": "../app-mailer" }, { "path": "../app-page-builder" }, { "path": "../app-security" }, @@ -59,6 +60,8 @@ "@webiny/app-i18n": ["../app-i18n/src"], "@webiny/app-i18n-content/*": ["../app-i18n-content/src/*"], "@webiny/app-i18n-content": ["../app-i18n-content/src"], + "@webiny/app-locking-mechanism/*": ["../app-locking-mechanism/src/*"], + "@webiny/app-locking-mechanism": ["../app-locking-mechanism/src"], "@webiny/app-mailer/*": ["../app-mailer/src/*"], "@webiny/app-mailer": ["../app-mailer/src"], "@webiny/app-page-builder/*": ["../app-page-builder/src/*"], diff --git a/packages/app-trash-bin/package.json b/packages/app-trash-bin/package.json index 9c696b7885b..f669b23e1f4 100644 --- a/packages/app-trash-bin/package.json +++ b/packages/app-trash-bin/package.json @@ -12,7 +12,7 @@ "license": "MIT", "dependencies": { "@emotion/styled": "^11.10.6", - "@material-design-icons/svg": "^0.12.1", + "@material-design-icons/svg": "^0.14.2", "@webiny/app-aco": "0.0.0", "@webiny/app-admin": "0.0.0", "@webiny/app-utils": "0.0.0", diff --git a/packages/app-wcp/src/WcpProvider.tsx b/packages/app-wcp/src/WcpProvider.tsx index 5c996e94342..34d625968ec 100644 --- a/packages/app-wcp/src/WcpProvider.tsx +++ b/packages/app-wcp/src/WcpProvider.tsx @@ -31,6 +31,9 @@ export const GET_WCP_PROJECT = gql` auditLogs { enabled } + recordLocking { + enabled + } } } } diff --git a/packages/app-websockets/src/WebsocketsProvider.tsx b/packages/app-websockets/src/WebsocketsContextProvider.tsx similarity index 78% rename from packages/app-websockets/src/WebsocketsProvider.tsx rename to packages/app-websockets/src/WebsocketsContextProvider.tsx index d00d807a628..9487ca5178c 100644 --- a/packages/app-websockets/src/WebsocketsProvider.tsx +++ b/packages/app-websockets/src/WebsocketsContextProvider.tsx @@ -7,19 +7,19 @@ import { IncomingGenericData, IWebsocketsContext, IWebsocketsContextSendCallable import { createWebsocketsAction, createWebsocketsActions, - createWebsocketsBlackHoleManager, createWebsocketsConnection, createWebsocketsManager, createWebsocketsSubscriptionManager } from "./domain"; import { IGenericData, IWebsocketsManager } from "./domain/types"; -export interface IWebsocketsProviderProps { +export interface IWebsocketsContextProviderProps { + loader?: React.ReactElement; children: React.ReactNode; } export const WebsocketsContext = React.createContext<IWebsocketsContext>( - {} as unknown as IWebsocketsContext + undefined as unknown as IWebsocketsContext ); interface ICurrentData { @@ -27,17 +27,34 @@ interface ICurrentData { locale?: string; } -export const WebsocketsProvider = (props: IWebsocketsProviderProps) => { +export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps) => { const { tenant } = useTenancy(); const { getCurrentLocale } = useI18N(); const locale = getCurrentLocale("default"); - const socketsRef = useRef<IWebsocketsManager>(createWebsocketsBlackHoleManager()); + const socketsRef = useRef<IWebsocketsManager>(); const [current, setCurrent] = useState<ICurrentData>({}); const subscriptionManager = useMemo(() => { - return createWebsocketsSubscriptionManager(); + const manager = createWebsocketsSubscriptionManager(); + + let currentIteration = 0; + manager.onClose(event => { + if (currentIteration > 5 || event.code !== 1001) { + return; + } + currentIteration++; + setTimeout(() => { + if (!socketsRef.current) { + return; + } + console.log("Running auto-reconnect."); + socketsRef.current.connect(); + }, 1000); + }); + + return manager; }, []); useEffect(() => { @@ -61,11 +78,6 @@ export const WebsocketsProvider = (props: IWebsocketsProviderProps) => { return; } - setCurrent({ - tenant, - locale - }); - socketsRef.current = createWebsocketsManager( createWebsocketsConnection({ subscriptionManager, @@ -74,12 +86,17 @@ export const WebsocketsProvider = (props: IWebsocketsProviderProps) => { }) ); socketsRef.current.connect(); + + setCurrent({ + tenant, + locale + }); })(); }, [tenant, locale, subscriptionManager]); const websocketActions = useMemo(() => { return createWebsocketsActions({ - manager: socketsRef.current, + manager: socketsRef.current!, tenant, locale, getToken @@ -111,7 +128,7 @@ export const WebsocketsProvider = (props: IWebsocketsProviderProps) => { action: string, cb: (data: T) => void ) => { - return socketsRef.current.onMessage<T>(async event => { + return socketsRef.current!.onMessage<T>(async event => { if (event.data.action !== action) { return; } @@ -121,6 +138,10 @@ export const WebsocketsProvider = (props: IWebsocketsProviderProps) => { [socketsRef.current] ); + if (!socketsRef.current) { + return props.loader || null; + } + // TODO remove when finished with development (window as any).webinySockets = socketsRef.current; (window as any).send = send; diff --git a/packages/app-websockets/src/domain/WebsocketsConnection.ts b/packages/app-websockets/src/domain/WebsocketsConnection.ts index d175db26065..c4782ef6aad 100644 --- a/packages/app-websockets/src/domain/WebsocketsConnection.ts +++ b/packages/app-websockets/src/domain/WebsocketsConnection.ts @@ -47,15 +47,13 @@ export class WebsocketsConnection implements IWebsocketsConnection { this.connection = this.factory(this.url, this.protocol); this.connection.addEventListener("open", event => { - console.info(`Opened the Websocket connection.`); return this.subscriptionManager.triggerOnOpen(event); }); this.connection.addEventListener("close", event => { - console.info(`Closed the Websocket connection.`); return this.subscriptionManager.triggerOnClose(event); }); this.connection.addEventListener("error", event => { - console.info(`Error in the Websocket connection.`); + console.info(`Error in the Websocket connection.`, event); return this.subscriptionManager.triggerOnError(event); }); this.connection.addEventListener( diff --git a/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts b/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts index 99375c6afce..eb4252781a0 100644 --- a/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts +++ b/packages/app-websockets/src/domain/abstractions/IWebsocketsSubscriptionManager.ts @@ -10,7 +10,7 @@ import { export type IWebsocketManagerEvent = "open" | "close" | "error" | "message"; export interface IWebsocketsSubscriptionCallback<T> { - (data: T): Promise<void>; + (data: T): Promise<void> | void; } export interface IWebsocketsSubscription<T> { diff --git a/packages/app-websockets/src/hooks/useWebsockets.tsx b/packages/app-websockets/src/hooks/useWebsockets.tsx index f5e67e9ec5c..aee54d9bb60 100644 --- a/packages/app-websockets/src/hooks/useWebsockets.tsx +++ b/packages/app-websockets/src/hooks/useWebsockets.tsx @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { WebsocketsContext } from "~/WebsocketsProvider"; +import { WebsocketsContext } from "~/WebsocketsContextProvider"; import { IWebsocketsContext } from "~/types"; export const useWebsockets = (): IWebsocketsContext => { diff --git a/packages/app-websockets/src/index.tsx b/packages/app-websockets/src/index.tsx index dcabda618dd..51b86e8bb80 100644 --- a/packages/app-websockets/src/index.tsx +++ b/packages/app-websockets/src/index.tsx @@ -1,17 +1,17 @@ import React from "react"; import { Provider } from "@webiny/app"; -import { WebsocketsProvider as WebsocketsProviderComponent } from "~/WebsocketsProvider"; +import { WebsocketsContextProvider } from "~/WebsocketsContextProvider"; export interface WebsocketsProviderProps { children: React.ReactNode; } const WebsocketsHoc = (Component: React.ComponentType) => { - return function WebsocketsProvider({ children }: WebsocketsProviderProps) { + return function WebsocketsProvider(props: WebsocketsProviderProps) { return ( - <Component> - <WebsocketsProviderComponent>{children}</WebsocketsProviderComponent> - </Component> + <WebsocketsContextProvider> + <Component {...props} /> + </WebsocketsContextProvider> ); }; }; diff --git a/packages/app-websockets/src/types.ts b/packages/app-websockets/src/types.ts index 8ac28db1f79..9d6824ef108 100644 --- a/packages/app-websockets/src/types.ts +++ b/packages/app-websockets/src/types.ts @@ -18,12 +18,11 @@ export interface IWebsocketsContextCreateActionCallable< (name: string): IWebsocketsAction<T, R>; } -export interface ISocketsContextOnMessageCallable< - T extends IncomingGenericData = IncomingGenericData -> { - (action: string, cb: (data: T) => void): IWebsocketsSubscription< - IWebsocketsManagerMessageEvent<T> - >; +export interface ISocketsContextOnMessageCallable { + <T extends IncomingGenericData = IncomingGenericData>( + action: string, + cb: (data: T) => void + ): IWebsocketsSubscription<IWebsocketsManagerMessageEvent<T>>; } export interface IWebsocketsContext { diff --git a/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts b/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts index 7d8d7422f96..eb4e360f938 100644 --- a/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts +++ b/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts @@ -1,5 +1,5 @@ import { DynamoDBClient, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; -import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; +import { DynamoDBDocument, TranslateConfig } from "@aws-sdk/lib-dynamodb"; import crypto from "crypto"; const DEFAULT_CONFIG = { @@ -14,6 +14,16 @@ const createKey = (config: DynamoDBClientConfig): string => { }; const documentClients: Record<string, DynamoDBDocument> = {}; +/** + * We do not want users to be able to change these options, so we are not exposing them. + */ +const documentClientConfig: TranslateConfig = { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + convertClassInstanceToMap: true + } +}; export const getDocumentClient = (input?: DynamoDBClientConfig): DynamoDBDocument => { const config = input || DEFAULT_CONFIG; @@ -22,13 +32,8 @@ export const getDocumentClient = (input?: DynamoDBClientConfig): DynamoDBDocumen return documentClients[key]; } const client = new DynamoDBClient(config); - const documentClient = DynamoDBDocument.from(client, { - marshallOptions: { - convertEmptyValues: true, - removeUndefinedValues: true, - convertClassInstanceToMap: true - } - }); + + const documentClient = DynamoDBDocument.from(client, documentClientConfig); documentClients[key] = documentClient; return documentClient; diff --git a/packages/error/src/index.ts b/packages/error/src/index.ts index 08b57ea358d..8d5f0dcda60 100644 --- a/packages/error/src/index.ts +++ b/packages/error/src/index.ts @@ -1,4 +1,5 @@ import Error, { ErrorOptions } from "./Error"; +export { Error as WebinyError }; export default Error; export { ErrorOptions }; diff --git a/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts b/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts index b4f7f55678a..7c481f9b2c1 100644 --- a/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts +++ b/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts @@ -2,14 +2,21 @@ import { Plugin } from "@webiny/plugins"; import { GraphQLSchemaDefinition, Resolvers, Types } from "~/types"; import { Context } from "@webiny/api/types"; +export interface IGraphQLSchemaPlugin<TContext = Context> extends Plugin { + schema: GraphQLSchemaDefinition<TContext>; +} + export interface GraphQLSchemaPluginConfig<TContext> { typeDefs?: Types; resolvers?: Resolvers<TContext>; } -export class GraphQLSchemaPlugin<TContext = Context> extends Plugin { +export class GraphQLSchemaPlugin<TContext = Context> + extends Plugin + implements IGraphQLSchemaPlugin<TContext> +{ public static override readonly type: string = "graphql-schema"; - private config: GraphQLSchemaPluginConfig<TContext>; + protected config: GraphQLSchemaPluginConfig<TContext>; constructor(config: GraphQLSchemaPluginConfig<TContext>) { super(); @@ -24,8 +31,6 @@ export class GraphQLSchemaPlugin<TContext = Context> extends Plugin { } } -export const createGraphQLSchemaPlugin = <TContext = Context>( - params: GraphQLSchemaPluginConfig<TContext> -) => { - return new GraphQLSchemaPlugin<TContext>(params); +export const createGraphQLSchemaPlugin = <T = Context>(config: GraphQLSchemaPluginConfig<T>) => { + return new GraphQLSchemaPlugin<T>(config); }; diff --git a/yarn.lock b/yarn.lock index 854c87c544f..7789aa2ae13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7643,10 +7643,10 @@ __metadata: languageName: node linkType: hard -"@material-design-icons/svg@npm:^0.12.1": - version: 0.12.1 - resolution: "@material-design-icons/svg@npm:0.12.1" - checksum: c0e482fd0be54d3cc5aa84ab12bc4286443acb22bb6e0be3c5ec8e4405977031b4559a4ffbc892e59cd25f5694f65e61af647666926c0768766bfa6acd218f33 +"@material-design-icons/svg@npm:^0.14.13": + version: 0.14.13 + resolution: "@material-design-icons/svg@npm:0.14.13" + checksum: 80815e94a7b4a9775a4afe2af1b24c3b55270e09ad7cbebf669d7cea2d8c31050c371e435257bfb6289c782f672b82c73873c5d4d5bd15b12ec7d9b8b58909d4 languageName: node linkType: hard @@ -13326,7 +13326,7 @@ __metadata: "@webiny/validation": 0.0.0 jest: ^29.7.0 lodash: 4.17.21 - object-hash: ^2.1.1 + object-hash: ^3.0.0 rimraf: ^5.0.5 ttypescript: ^1.5.12 typescript: 4.7.4 @@ -13706,6 +13706,40 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-locking-mechanism@0.0.0, @webiny/api-locking-mechanism@workspace:packages/api-locking-mechanism": + version: 0.0.0-use.local + resolution: "@webiny/api-locking-mechanism@workspace:packages/api-locking-mechanism" + dependencies: + "@babel/cli": ^7.23.9 + "@babel/core": ^7.24.0 + "@babel/preset-env": ^7.24.0 + "@babel/preset-typescript": ^7.23.3 + "@babel/runtime": ^7.24.0 + "@types/aws-lambda": ^8.10.131 + "@webiny/api": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-i18n": 0.0.0 + "@webiny/api-security": 0.0.0 + "@webiny/api-tenancy": 0.0.0 + "@webiny/api-wcp": 0.0.0 + "@webiny/api-websockets": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/error": 0.0.0 + "@webiny/handler": 0.0.0 + "@webiny/handler-aws": 0.0.0 + "@webiny/handler-graphql": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/pubsub": 0.0.0 + "@webiny/utils": 0.0.0 + graphql: ^15.8.0 + rimraf: ^5.0.5 + ttypescript: ^1.5.13 + type-fest: ^2.19.0 + typescript: 4.7.4 + languageName: unknown + linkType: soft + "@webiny/api-mailer@0.0.0, @webiny/api-mailer@workspace:packages/api-mailer": version: 0.0.0-use.local resolution: "@webiny/api-mailer@workspace:packages/api-mailer" @@ -14068,7 +14102,7 @@ __metadata: "@webiny/project-utils": 0.0.0 "@webiny/utils": 0.0.0 lodash: 4.17.21 - object-hash: ^2.1.1 + object-hash: ^3.0.0 pluralize: ^8.0.0 posthtml: ^0.15.0 posthtml-noopener: ^1.0.5 @@ -14405,7 +14439,7 @@ __metadata: "@babel/runtime": ^7.24.0 "@emotion/react": ^11.10.6 "@emotion/styled": ^11.10.6 - "@material-design-icons/svg": ^0.12.1 + "@material-design-icons/svg": ^0.14.2 "@material-symbols/svg-400": ^0.4.1 "@minoru/react-dnd-treeview": 3.2.1 "@types/react": 17.0.39 @@ -14575,7 +14609,7 @@ __metadata: "@babel/runtime": ^7.24.0 "@emotion/babel-plugin": ^11.11.0 "@emotion/styled": ^11.10.6 - "@material-design-icons/svg": ^0.12.1 + "@material-design-icons/svg": ^0.14.2 "@rmwc/base": 7.0.3 "@rmwc/provider": 7.0.3 "@types/react": 17.0.39 @@ -15188,6 +15222,43 @@ __metadata: languageName: unknown linkType: soft +"@webiny/app-locking-mechanism@0.0.0, @webiny/app-locking-mechanism@workspace:packages/app-locking-mechanism": + version: 0.0.0-use.local + resolution: "@webiny/app-locking-mechanism@workspace:packages/app-locking-mechanism" + dependencies: + "@apollo/react-hooks": ^3.1.5 + "@babel/cli": ^7.23.9 + "@babel/core": ^7.24.0 + "@babel/preset-env": ^7.24.0 + "@babel/preset-react": ^7.23.3 + "@babel/preset-typescript": ^7.23.3 + "@emotion/styled": ^11.10.6 + "@material-design-icons/svg": ^0.14.13 + "@webiny/app": 0.0.0 + "@webiny/app-admin": 0.0.0 + "@webiny/app-headless-cms": 0.0.0 + "@webiny/app-security": 0.0.0 + "@webiny/app-wcp": 0.0.0 + "@webiny/app-websockets": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/error": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/react-router": 0.0.0 + "@webiny/ui": 0.0.0 + "@webiny/utils": 0.0.0 + apollo-client: ^2.6.10 + apollo-link: ^1.2.14 + crypto-hash: ^3.0.0 + emotion: ^10.0.27 + graphql-tag: ^2.12.6 + react: 17.0.2 + react-dom: 17.0.2 + rimraf: ^5.0.5 + ttypescript: ^1.5.12 + typescript: 4.7.4 + languageName: unknown + linkType: soft + "@webiny/app-mailer@0.0.0, @webiny/app-mailer@workspace:packages/app-mailer": version: 0.0.0-use.local resolution: "@webiny/app-mailer@workspace:packages/app-mailer" @@ -15451,7 +15522,7 @@ __metadata: "@babel/preset-typescript": ^7.23.3 "@emotion/babel-plugin": ^11.11.0 "@emotion/styled": ^11.10.6 - "@material-design-icons/svg": ^0.12.1 + "@material-design-icons/svg": ^0.14.2 "@types/react-helmet": ^6.1.5 "@webiny/app": 0.0.0 "@webiny/app-admin": 0.0.0 @@ -15525,6 +15596,7 @@ __metadata: "@webiny/app-headless-cms": 0.0.0 "@webiny/app-i18n": 0.0.0 "@webiny/app-i18n-content": 0.0.0 + "@webiny/app-locking-mechanism": 0.0.0 "@webiny/app-mailer": 0.0.0 "@webiny/app-page-builder": 0.0.0 "@webiny/app-security": 0.0.0 @@ -15704,7 +15776,7 @@ __metadata: "@babel/preset-typescript": ^7.23.3 "@babel/runtime": ^7.24.0 "@emotion/styled": ^11.10.6 - "@material-design-icons/svg": ^0.12.1 + "@material-design-icons/svg": ^0.14.2 "@types/react": 17.0.39 "@webiny/app-aco": 0.0.0 "@webiny/app-admin": 0.0.0 @@ -18044,6 +18116,7 @@ __metadata: "@webiny/api-i18n": 0.0.0 "@webiny/api-i18n-content": 0.0.0 "@webiny/api-i18n-ddb": 0.0.0 + "@webiny/api-locking-mechanism": 0.0.0 "@webiny/api-page-builder": 0.0.0 "@webiny/api-page-builder-aco": 0.0.0 "@webiny/api-page-builder-import-export": 0.0.0 @@ -21339,6 +21412,13 @@ __metadata: languageName: node linkType: hard +"crypto-hash@npm:^3.0.0": + version: 3.0.0 + resolution: "crypto-hash@npm:3.0.0" + checksum: 90ad0488b1ce58ca9b22cefa83e789d95cfe9eef91e40b9601bc79b60f6bd74ba9f3e0449b5ba62efc1539aad932553e7b96918cccb7cf31260d34d80c3ec887 + languageName: node + linkType: hard + "crypto-js@npm:^4.2.0": version: 4.2.0 resolution: "crypto-js@npm:4.2.0" @@ -31115,13 +31195,6 @@ __metadata: languageName: node linkType: hard -"object-hash@npm:^2.1.1": - version: 2.2.0 - resolution: "object-hash@npm:2.2.0" - checksum: 55ba841e3adce9c4f1b9b46b41983eda40f854e0d01af2802d3ae18a7085a17168d6b81731d43fdf1d6bcbb3c9f9c56d22c8fea992203ad90a38d7d919bc28f1 - languageName: node - linkType: hard - "object-hash@npm:^3.0.0": version: 3.0.0 resolution: "object-hash@npm:3.0.0"