From a041a2c00432c86e84f9be002b3943a629906d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Mon, 11 Oct 2021 14:02:35 +0200 Subject: [PATCH] feat(api-form-builder): storage operations (#1942) --- api/code/graphql/package.json | 1 + api/code/graphql/src/index.ts | 27 +- api/code/graphql/tsconfig.json | 7 + .../api-elasticsearch/__tests__/where.test.ts | 54 + packages/api-elasticsearch/src/client.ts | 25 + packages/api-elasticsearch/src/index.ts | 63 +- packages/api-elasticsearch/src/operators.ts | 34 + .../ElasticsearchBodyModifierPlugin.ts | 18 +- .../definition/ElasticsearchFieldPlugin.ts | 17 +- .../ElasticsearchQueryModifierPlugin.ts | 18 +- .../ElasticsearchSortModifierPlugin.ts | 18 +- packages/api-elasticsearch/src/sort.ts | 12 +- packages/api-elasticsearch/src/types.ts | 8 +- packages/api-elasticsearch/src/where.ts | 100 + .../src/operations/configurations.ts | 2 +- .../src/operations/files/body.ts | 98 +- .../FileElasticsearchBodyModifierPlugin.ts | 3 +- .../FileElasticsearchQueryModifierPlugin.ts | 3 +- .../FileElasticsearchSortModifierPlugin.ts | 3 +- .../src/operations/configurations.ts | 2 +- .../__tests__/filesSettings.test.ts | 30 +- .../__tests__/useGqlHandler.ts | 4 +- .../api-form-builder-so-ddb-es/.babelrc.js | 1 + .../api-form-builder-so-ddb-es/CHANGELOG.md | 4 + packages/api-form-builder-so-ddb-es/LICENSE | 21 + packages/api-form-builder-so-ddb-es/README.md | 17 + .../__tests__/__api__/environment.js | 104 + .../__tests__/__api__/presets.js | 15 + .../__tests__/__api__/setupAfterEnv.js | 28 + .../jest-dynalite-config.js | 2 +- .../jest.config.js | 0 .../api-form-builder-so-ddb-es/package.json | 67 + .../src/configurations.ts | 18 + .../src/definitions/elasticsearch.ts | 35 + .../src/definitions/form.ts | 91 + .../src/definitions/settings.ts | 40 + .../src/definitions/submission.ts | 61 + .../src/definitions/system.ts | 31 + .../src/definitions/table.ts | 18 + .../src/definitions/tableElasticsearch.ts | 17 + .../api-form-builder-so-ddb-es/src/index.ts | 158 ++ .../src/operations/form/elasticsearchBody.ts | 182 ++ .../operations/form/elasticsearchFields.ts | 26 + .../src/operations/form/fields.ts | 8 + .../src/operations/form/index.ts | 973 +++++++++ .../src/operations/settings/index.ts | 141 ++ .../submission/elasticsearchBody.ts | 191 ++ .../submission/elasticsearchFields.ts | 26 + .../src/operations/submission/index.ts | 373 ++++ .../system/createElasticsearchIndex.ts | 54 + .../src/operations/system/index.ts | 115 ++ .../src/plugins/FormDynamoDbFieldPlugin.ts | 5 + .../FormElasticsearchBodyModifierPlugin.ts | 5 + .../plugins/FormElasticsearchFieldPlugin.ts | 5 + .../FormElasticsearchQueryModifierPlugin.ts | 5 + .../FormElasticsearchSortModifierPlugin.ts | 5 + ...bmissionElasticsearchBodyModifierPlugin.ts | 5 + .../SubmissionElasticsearchFieldPlugin.ts | 5 + ...missionElasticsearchQueryModifierPlugin.ts | 5 + ...bmissionElasticsearchSortModifierPlugin.ts | 5 + .../api-form-builder-so-ddb-es/src/types.ts | 101 + .../src/upgrades/5.16.0/index.ts | 121 ++ .../tsconfig.build.json | 30 + .../api-form-builder-so-ddb-es/tsconfig.json | 76 + .../webiny.config.js | 8 + ...test.js => formSubmissionSecurity.test.ts} | 152 +- .../{forms.test.js => forms.test.ts} | 70 +- ...Security.test.js => formsSecurity.test.ts} | 61 +- ...agerSettings.js => fileManagerSettings.ts} | 0 ...lderSettings.js => formBuilderSettings.ts} | 0 .../{formSubmission.js => formSubmission.ts} | 2 +- .../__tests__/graphql/{forms.js => forms.ts} | 6 + .../api-form-builder/__tests__/helpers.ts | 43 + .../mocks/{form.mocks.js => form.mocks.ts} | 0 .../{settings.test.js => settings.test.ts} | 52 +- ...urity.test.js => settingsSecurity.test.ts} | 24 +- .../{useGqlHandler.js => useGqlHandler.ts} | 119 +- packages/api-form-builder/jest.setup.js | 14 + packages/api-form-builder/package.json | 8 +- packages/api-form-builder/src/index.ts | 24 + .../src/plugins/crud/defaults.ts | 43 - .../src/plugins/crud/forms.crud.ts | 1839 ++++++----------- .../src/plugins/crud/forms.models.ts | 19 +- .../src/plugins/crud/index.ts | 61 + .../src/plugins/crud/settings.crud.ts | 180 +- .../src/plugins/crud/settings.models.ts | 1 - .../src/plugins/crud/submissions.crud.ts | 380 ++++ .../src/plugins/crud/system.crud.ts | 286 +-- .../src/plugins/crud/utils.ts | 91 +- .../api-form-builder/src/plugins/graphql.ts | 8 +- .../src/plugins/graphql/form.ts | 132 +- .../src/plugins/graphql/formSettings.ts | 19 +- .../api-form-builder/src/plugins/index.ts | 21 - .../src/plugins/triggers/triggerHandlers.ts | 10 +- .../src/plugins/upgrades/index.ts | 7 +- .../src/plugins/upgrades/utils.ts | 7 - .../src/plugins/upgrades/v5.0.0/index.ts | 150 -- .../src/plugins/validators/in.ts | 2 +- .../src/plugins/validators/lte.ts | 2 +- .../src/plugins/validators/maxLength.ts | 2 +- .../src/plugins/validators/minLength.ts | 2 +- .../src/plugins/validators/pattern.ts | 2 +- .../validators/patternPlugins/email.ts | 2 +- .../validators/patternPlugins/lowerCase.ts | 2 +- .../validators/patternPlugins/upperCase.ts | 2 +- .../plugins/validators/patternPlugins/url.ts | 2 +- .../src/plugins/validators/required.ts | 2 +- packages/api-form-builder/src/types.ts | 549 ++++- packages/api-form-builder/tsconfig.build.json | 8 +- packages/api-form-builder/tsconfig.json | 20 +- .../src/configurations.ts | 2 +- .../helpers/createElasticsearchQueryBody.ts | 34 +- .../src/configurations.ts | 2 +- .../contentAPI/resolvers.read.test.ts | 12 +- .../src/operations/configurations.ts | 2 +- .../src/operations/configurations.ts | 2 +- .../pages/elasticsearchQueryBody.ts | 96 +- .../src/configurations.ts | 2 +- .../FormSubmissionsList/useSubmissions.ts | 4 +- .../src/admin/plugins/installation.tsx | 6 + .../src/admin/plugins/upgrades/v5.16.0.tsx | 93 + .../commands/upgrade/upgrades/5.16.0/index.js | 253 +++ .../cli/commands/upgrade/upgrades/upgrade.js | 2 +- .../template/api/code/graphql/package.json | 1 + .../template/api/code/graphql/src/index.ts | 29 +- .../src/plugins/definitions/FieldPlugin.ts | 14 + packages/db-dynamodb/src/utils/sort.ts | 20 +- packages/handler-db/src/index.ts | 15 +- packages/pubsub/package.json | 3 + packages/utils/.babelrc.js | 1 + packages/utils/LICENSE | 21 + packages/utils/README.md | 25 + .../utils/__tests__/createIdentifier.test.ts | 22 + .../utils/__tests__/parseIdentifier.test.ts | 47 + packages/utils/__tests__/zeroPad.test.ts | 17 + packages/utils/jest.config.js | 5 + packages/utils/package.json | 33 + packages/utils/src/createIdentifier.ts | 20 + packages/utils/src/index.ts | 5 + packages/utils/src/parseIdentifier.ts | 17 + packages/utils/src/zeroPad.ts | 7 + packages/utils/tsconfig.build.json | 15 + packages/utils/tsconfig.json | 11 + packages/utils/webiny.config.js | 8 + yarn.lock | 484 +++-- 145 files changed, 6862 insertions(+), 2639 deletions(-) create mode 100644 packages/api-elasticsearch/__tests__/where.test.ts create mode 100644 packages/api-elasticsearch/src/client.ts create mode 100644 packages/api-elasticsearch/src/operators.ts create mode 100644 packages/api-elasticsearch/src/where.ts create mode 100644 packages/api-form-builder-so-ddb-es/.babelrc.js create mode 100644 packages/api-form-builder-so-ddb-es/CHANGELOG.md create mode 100644 packages/api-form-builder-so-ddb-es/LICENSE create mode 100644 packages/api-form-builder-so-ddb-es/README.md create mode 100644 packages/api-form-builder-so-ddb-es/__tests__/__api__/environment.js create mode 100644 packages/api-form-builder-so-ddb-es/__tests__/__api__/presets.js create mode 100644 packages/api-form-builder-so-ddb-es/__tests__/__api__/setupAfterEnv.js rename packages/{api-form-builder => api-form-builder-so-ddb-es}/jest-dynalite-config.js (96%) rename packages/{api-form-builder => api-form-builder-so-ddb-es}/jest.config.js (100%) create mode 100644 packages/api-form-builder-so-ddb-es/package.json create mode 100644 packages/api-form-builder-so-ddb-es/src/configurations.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/form.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/settings.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/submission.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/system.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/table.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/definitions/tableElasticsearch.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/index.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchBody.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchFields.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/form/fields.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/form/index.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/settings/index.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchBody.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchFields.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/system/createElasticsearchIndex.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/operations/system/index.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/FormDynamoDbFieldPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchBodyModifierPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchFieldPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchQueryModifierPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchSortModifierPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchBodyModifierPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchFieldPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchQueryModifierPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchSortModifierPlugin.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/types.ts create mode 100644 packages/api-form-builder-so-ddb-es/src/upgrades/5.16.0/index.ts create mode 100644 packages/api-form-builder-so-ddb-es/tsconfig.build.json create mode 100644 packages/api-form-builder-so-ddb-es/tsconfig.json create mode 100644 packages/api-form-builder-so-ddb-es/webiny.config.js rename packages/api-form-builder/__tests__/{formSubmissionSecurity.test.js => formSubmissionSecurity.test.ts} (51%) rename packages/api-form-builder/__tests__/{forms.test.js => forms.test.ts} (88%) rename packages/api-form-builder/__tests__/{formsSecurity.test.js => formsSecurity.test.ts} (95%) rename packages/api-form-builder/__tests__/graphql/{fileManagerSettings.js => fileManagerSettings.ts} (100%) rename packages/api-form-builder/__tests__/graphql/{formBuilderSettings.js => formBuilderSettings.ts} (100%) rename packages/api-form-builder/__tests__/graphql/{formSubmission.js => formSubmission.ts} (94%) rename packages/api-form-builder/__tests__/graphql/{forms.js => forms.ts} (97%) create mode 100644 packages/api-form-builder/__tests__/helpers.ts rename packages/api-form-builder/__tests__/mocks/{form.mocks.js => form.mocks.ts} (100%) rename packages/api-form-builder/__tests__/{settings.test.js => settings.test.ts} (67%) rename packages/api-form-builder/__tests__/{settingsSecurity.test.js => settingsSecurity.test.ts} (86%) rename packages/api-form-builder/__tests__/{useGqlHandler.js => useGqlHandler.ts} (71%) create mode 100644 packages/api-form-builder/jest.setup.js create mode 100644 packages/api-form-builder/src/index.ts delete mode 100644 packages/api-form-builder/src/plugins/crud/defaults.ts create mode 100644 packages/api-form-builder/src/plugins/crud/index.ts create mode 100644 packages/api-form-builder/src/plugins/crud/submissions.crud.ts delete mode 100644 packages/api-form-builder/src/plugins/index.ts delete mode 100644 packages/api-form-builder/src/plugins/upgrades/utils.ts delete mode 100644 packages/api-form-builder/src/plugins/upgrades/v5.0.0/index.ts create mode 100644 packages/app-form-builder/src/admin/plugins/upgrades/v5.16.0.tsx create mode 100644 packages/cli/commands/upgrade/upgrades/5.16.0/index.js create mode 100644 packages/utils/.babelrc.js create mode 100644 packages/utils/LICENSE create mode 100644 packages/utils/README.md create mode 100644 packages/utils/__tests__/createIdentifier.test.ts create mode 100644 packages/utils/__tests__/parseIdentifier.test.ts create mode 100644 packages/utils/__tests__/zeroPad.test.ts create mode 100644 packages/utils/jest.config.js create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/createIdentifier.ts create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/parseIdentifier.ts create mode 100644 packages/utils/src/zeroPad.ts create mode 100644 packages/utils/tsconfig.build.json create mode 100644 packages/utils/tsconfig.json create mode 100644 packages/utils/webiny.config.js diff --git a/api/code/graphql/package.json b/api/code/graphql/package.json index 88f9daff6b5..05e84aeb5c9 100644 --- a/api/code/graphql/package.json +++ b/api/code/graphql/package.json @@ -11,6 +11,7 @@ "@webiny/api-file-manager-ddb-es": "^5.15.0", "@webiny/api-file-manager-s3": "^5.15.0", "@webiny/api-form-builder": "^5.15.0", + "@webiny/api-form-builder-so-ddb-es": "^5.15.0", "@webiny/api-headless-cms": "^5.15.0", "@webiny/api-headless-cms-ddb-es": "^5.15.0", "@webiny/api-i18n": "^5.15.0", diff --git a/api/code/graphql/src/index.ts b/api/code/graphql/src/index.ts index 4aa0ece8f4c..3b174d77d95 100644 --- a/api/code/graphql/src/index.ts +++ b/api/code/graphql/src/index.ts @@ -18,30 +18,38 @@ import fileManagerPlugins from "@webiny/api-file-manager/plugins"; import fileManagerDynamoDbElasticStorageOperation from "@webiny/api-file-manager-ddb-es"; import logsPlugins from "@webiny/handler-logs"; import fileManagerS3 from "@webiny/api-file-manager-s3"; -import formBuilderPlugins from "@webiny/api-form-builder/plugins"; +import { createFormBuilder } from "@webiny/api-form-builder"; import securityPlugins from "./security"; import headlessCmsPlugins from "@webiny/api-headless-cms/plugins"; import headlessCmsDynamoDbElasticStorageOperation from "@webiny/api-headless-cms-ddb-es"; import elasticsearchDataGzipCompression from "@webiny/api-elasticsearch/plugins/GzipCompression"; +import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb-es"; // Imports plugins created via scaffolding utilities. import scaffoldsPlugins from "./plugins/scaffolds"; +import { createElasticsearchClient } from "@webiny/api-elasticsearch/client"; const debug = process.env.DEBUG === "true"; +const documentClient = new DocumentClient({ + convertEmptyValues: true, + region: process.env.AWS_REGION +}); + +const elasticsearchClient = createElasticsearchClient({ + endpoint: `https://${process.env.ELASTIC_SEARCH_ENDPOINT}` +}); + export const handler = createHandler({ plugins: [ dynamoDbPlugins(), logsPlugins(), graphqlPlugins({ debug }), - elasticSearch({ endpoint: `https://${process.env.ELASTIC_SEARCH_ENDPOINT}` }), + elasticSearch(elasticsearchClient), dbPlugins({ table: process.env.DB_TABLE, driver: new DynamoDbDriver({ - documentClient: new DocumentClient({ - convertEmptyValues: true, - region: process.env.AWS_REGION - }) + documentClient }) }), securityPlugins(), @@ -67,7 +75,12 @@ export const handler = createHandler({ pageBuilderPlugins(), pageBuilderDynamoDbElasticsearchPlugins(), pageBuilderPrerenderingPlugins(), - formBuilderPlugins(), + createFormBuilder({ + storageOperations: createFormBuilderStorageOperations({ + documentClient, + elasticsearch: elasticsearchClient + }) + }), headlessCmsPlugins(), headlessCmsDynamoDbElasticStorageOperation(), scaffoldsPlugins(), diff --git a/api/code/graphql/tsconfig.json b/api/code/graphql/tsconfig.json index 633df847f48..dc95dfb6967 100644 --- a/api/code/graphql/tsconfig.json +++ b/api/code/graphql/tsconfig.json @@ -53,6 +53,9 @@ { "path": "../../../packages/api-form-builder" }, + { + "path": "../../../packages/api-form-builder-so-ddb-es" + }, { "path": "../../../packages/api-elasticsearch" }, @@ -116,6 +119,10 @@ "@webiny/api-page-builder": ["../../../packages/api-page-builder/src"], "@webiny/api-form-builder/*": ["../../../packages/api-form-builder/src/*"], "@webiny/api-form-builder": ["../../../packages/api-form-builder/src"], + "@webiny/api-form-builder-so-ddb-es/*": [ + "../../../packages/api-form-builder-so-ddb-es/src/*" + ], + "@webiny/api-form-builder-so-ddb-es": ["../../../packages/api-form-builder-so-ddb-es/src"], "@webiny/api-security/*": ["../../../packages/api-security/src/*"], "@webiny/api-security": ["../../../packages/api-security/src"], "@webiny/api-tenancy/*": ["../../../packages/api-tenancy/src/*"], diff --git a/packages/api-elasticsearch/__tests__/where.test.ts b/packages/api-elasticsearch/__tests__/where.test.ts new file mode 100644 index 00000000000..5672e4e17bf --- /dev/null +++ b/packages/api-elasticsearch/__tests__/where.test.ts @@ -0,0 +1,54 @@ +import { parseWhereKey } from "~/where"; + +describe("where", () => { + const whereKeys = [ + [ + "id", + { + field: "id", + operator: "eq" + } + ], + [ + "id_in", + { + field: "id", + operator: "in" + } + ], + [ + "id_not_in", + { + field: "id", + operator: "not_in" + } + ] + ]; + + test.each(whereKeys)( + "parse should result in field and operator values", + (key: string, expected: any) => { + const result = parseWhereKey(key); + + expect(result).toEqual(expected); + } + ); + + const malformedWhereKeys = [["_a"], ["_"], ["__"], ["a_"]]; + + test.each(malformedWhereKeys)( + `should throw error when malformed key is passed "%s"`, + (key: string) => { + expect(() => { + parseWhereKey(key); + }).toThrow(`It is not possible to search by key "${key}"`); + } + ); + + test("should throw error when malformed field is parsed out", () => { + const key = "a0_in"; + expect(() => { + parseWhereKey(key); + }).toThrow(`Cannot filter by "a0".`); + }); +}); diff --git a/packages/api-elasticsearch/src/client.ts b/packages/api-elasticsearch/src/client.ts new file mode 100644 index 00000000000..d18436c6326 --- /dev/null +++ b/packages/api-elasticsearch/src/client.ts @@ -0,0 +1,25 @@ +import { Client, ClientOptions } from "@elastic/elasticsearch"; +import AWS from "aws-sdk"; +import createAwsElasticsearchConnector from "aws-elasticsearch-connector"; + +export interface ElasticsearchClientOptions extends ClientOptions { + endpoint?: string; +} + +export const createElasticsearchClient = (options: ElasticsearchClientOptions) => { + const { endpoint, node, ...rest } = options; + + const clientOptions: ClientOptions = { + node: endpoint || node, + ...rest + }; + + if (!clientOptions.auth) { + /** + * If no `auth` configuration is present, we setup AWS connector. + */ + Object.assign(clientOptions, createAwsElasticsearchConnector(AWS.config)); + } + + return new Client(clientOptions); +}; diff --git a/packages/api-elasticsearch/src/index.ts b/packages/api-elasticsearch/src/index.ts index 4959a798ae1..d6c6e1d82ba 100644 --- a/packages/api-elasticsearch/src/index.ts +++ b/packages/api-elasticsearch/src/index.ts @@ -1,31 +1,16 @@ -import { Client, ClientOptions } from "@elastic/elasticsearch"; -import AWS from "aws-sdk"; -import createAwsElasticsearchConnector from "aws-elasticsearch-connector"; import { ElasticsearchContext } from "~/types"; import { ContextPlugin } from "@webiny/handler/plugins/ContextPlugin"; -import { - ElasticsearchQueryBuilderOperatorBetweenPlugin, - ElasticsearchQueryBuilderOperatorNotBetweenPlugin, - ElasticsearchQueryBuilderOperatorContainsPlugin, - ElasticsearchQueryBuilderOperatorNotContainsPlugin, - ElasticsearchQueryBuilderOperatorEqualPlugin, - ElasticsearchQueryBuilderOperatorNotPlugin, - ElasticsearchQueryBuilderOperatorGreaterThanPlugin, - ElasticsearchQueryBuilderOperatorGreaterThanOrEqualToPlugin, - ElasticsearchQueryBuilderOperatorLesserThanPlugin, - ElasticsearchQueryBuilderOperatorLesserThanOrEqualToPlugin, - ElasticsearchQueryBuilderOperatorInPlugin, - ElasticsearchQueryBuilderOperatorAndInPlugin, - ElasticsearchQueryBuilderOperatorNotInPlugin -} from "~/plugins/operator"; import WebinyError from "@webiny/error"; +import { createElasticsearchClient, ElasticsearchClientOptions } from "~/client"; +import { getElasticsearchOperators } from "~/operators"; +import { Client } from "@elastic/elasticsearch"; -interface ElasticsearchClientOptions extends ClientOptions { - endpoint?: string; -} - -export default (options: ElasticsearchClientOptions): ContextPlugin => { - const { endpoint, node, ...rest } = options; +/** + * We must accept either Elasticsearch client or options that create the client. + */ +export default ( + params: ElasticsearchClientOptions | Client +): ContextPlugin => { return new ContextPlugin(context => { if (context.elasticsearch) { throw new WebinyError( @@ -33,36 +18,12 @@ export default (options: ElasticsearchClientOptions): ContextPlugin operators; diff --git a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchBodyModifierPlugin.ts b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchBodyModifierPlugin.ts index fd58a894388..055f7bbbdb0 100644 --- a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchBodyModifierPlugin.ts +++ b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchBodyModifierPlugin.ts @@ -1,28 +1,24 @@ import WebinyError from "@webiny/error"; import { Plugin } from "@webiny/plugins"; -import { ContextInterface } from "@webiny/handler/types"; import { SearchBody } from "elastic-ts"; -export interface ModifyBodyParams { - context: T; +export interface ModifyBodyParams { body: SearchBody; } -interface Callable { - (params: ModifyBodyParams): void; +interface Callable { + (params: ModifyBodyParams): void; } -export abstract class ElasticsearchBodyModifierPlugin< - T extends ContextInterface = ContextInterface -> extends Plugin { - private readonly callable?: Callable; +export abstract class ElasticsearchBodyModifierPlugin extends Plugin { + private readonly callable?: Callable; - public constructor(callable?: Callable) { + public constructor(callable?: Callable) { super(); this.callable = callable; } - public modifyBody(params: ModifyBodyParams): void { + public modifyBody(params: ModifyBodyParams): void { if (typeof this.callable !== "function") { throw new WebinyError( `Missing modification for the body.`, diff --git a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchFieldPlugin.ts b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchFieldPlugin.ts index 034ce5f4ce7..1fa058751c8 100644 --- a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchFieldPlugin.ts +++ b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchFieldPlugin.ts @@ -1,14 +1,18 @@ import { Plugin } from "@webiny/plugins"; import { FieldSortOptions, SortOrder } from "elastic-ts"; -import { ContextInterface } from "@webiny/handler/types"; export type UnmappedTypes = "date" | "long" | string; +const keywordLessUnmappedType = ["date", "long"]; + +const unmappedTypeHasKeyword = (type: string): boolean => { + if (keywordLessUnmappedType.includes(type)) { + return false; + } + return true; +}; + export interface ToSearchValueParams { - /** - * Some variable that has a ContextInterface as a base. - */ - context: ContextInterface; /** * The value to transform. */ @@ -96,6 +100,9 @@ export abstract class ElasticsearchFieldPlugin extends Plugin { this._path = params.path || params.field; this._keyword = params.keyword === undefined ? true : params.keyword; this._unmappedType = params.unmappedType; + if (unmappedTypeHasKeyword(params.unmappedType) === false) { + this._keyword = false; + } this._sortable = params.sortable === undefined ? true : params.sortable; this._searchable = params.searchable === undefined ? true : params.searchable; } diff --git a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchQueryModifierPlugin.ts b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchQueryModifierPlugin.ts index 9971ccae2b9..3553ab7f6c1 100644 --- a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchQueryModifierPlugin.ts +++ b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchQueryModifierPlugin.ts @@ -1,29 +1,25 @@ import WebinyError from "@webiny/error"; import { Plugin } from "@webiny/plugins"; import { ElasticsearchBoolQueryConfig } from "~/types"; -import { ContextInterface } from "@webiny/handler/types"; -export interface ModifyQueryParams { - context: T; +export interface ModifyQueryParams { query: ElasticsearchBoolQueryConfig; where: Record; } -interface Callable { - (params: ModifyQueryParams): void; +interface Callable { + (params: ModifyQueryParams): void; } -export abstract class ElasticsearchQueryModifierPlugin< - T extends ContextInterface = ContextInterface -> extends Plugin { - private readonly callable?: Callable; +export abstract class ElasticsearchQueryModifierPlugin extends Plugin { + private readonly callable?: Callable; - public constructor(callable?: Callable) { + public constructor(callable?: Callable) { super(); this.callable = callable; } - public modifyQuery(params: ModifyQueryParams): void { + public modifyQuery(params: ModifyQueryParams): void { if (typeof this.callable !== "function") { throw new WebinyError( `Missing modification for the query.`, diff --git a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchSortModifierPlugin.ts b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchSortModifierPlugin.ts index d07af40064b..6a439a3dd5b 100644 --- a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchSortModifierPlugin.ts +++ b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchSortModifierPlugin.ts @@ -1,28 +1,24 @@ import WebinyError from "@webiny/error"; import { Plugin } from "@webiny/plugins"; -import { ContextInterface } from "@webiny/handler/types"; import { Sort } from "elastic-ts"; -export interface ModifySortParams { - context: T; +export interface ModifySortParams { sort: Sort; } -interface Callable { - (params: ModifySortParams): void; +interface Callable { + (params: ModifySortParams): void; } -export abstract class ElasticsearchSortModifierPlugin< - T extends ContextInterface = ContextInterface -> extends Plugin { - private readonly callable?: Callable; +export abstract class ElasticsearchSortModifierPlugin extends Plugin { + private readonly callable?: Callable; - public constructor(callable?: Callable) { + public constructor(callable?: Callable) { super(); this.callable = callable; } - public modifySort(params: ModifySortParams): void { + public modifySort(params: ModifySortParams): void { if (typeof this.callable !== "function") { throw new WebinyError( `Missing modification for the sort.`, diff --git a/packages/api-elasticsearch/src/sort.ts b/packages/api-elasticsearch/src/sort.ts index dd5c15ec957..6ca73966ac2 100644 --- a/packages/api-elasticsearch/src/sort.ts +++ b/packages/api-elasticsearch/src/sort.ts @@ -1,22 +1,20 @@ -import { FieldSortOptions, SortType, SortOrder } from "./types"; import WebinyError from "@webiny/error"; +import { FieldSortOptions, SortType, SortOrder } from "./types"; import { ElasticsearchFieldPlugin } from "./plugins/definition/ElasticsearchFieldPlugin"; -import { ContextInterface } from "@webiny/handler/types"; const sortRegExp = new RegExp(/^([a-zA-Z-0-9_]+)_(ASC|DESC)$/); export interface Params { - context: ContextInterface; sort: string[]; defaults?: { field?: string; order?: SortOrder; unmappedType?: string; }; - plugins: Record; + fieldPlugins: Record; } export const createSort = (params: Params): SortType => { - const { sort, defaults, plugins } = params; + const { sort, defaults, fieldPlugins } = params; if (!sort || sort.length === 0) { const { field, order, unmappedType } = defaults || {}; /** @@ -40,9 +38,9 @@ export const createSort = (params: Params): SortType => { const [, field, initialOrder] = match; const order: SortOrder = initialOrder.toLowerCase() === "asc" ? "asc" : "desc"; - const plugin: ElasticsearchFieldPlugin = plugins[field] || plugins["*"]; + const plugin: ElasticsearchFieldPlugin = fieldPlugins[field] || fieldPlugins["*"]; if (!plugin) { - throw new WebinyError(`Missing plugin for the field "${field}"`, "PLUGIN_ERROR", { + throw new WebinyError(`Missing plugin for the field "${field}"`, "PLUGIN_SORT_ERROR", { field }); } diff --git a/packages/api-elasticsearch/src/types.ts b/packages/api-elasticsearch/src/types.ts index b838199d321..dda770e9afa 100644 --- a/packages/api-elasticsearch/src/types.ts +++ b/packages/api-elasticsearch/src/types.ts @@ -45,9 +45,7 @@ export type ElasticsearchQueryOperator = * @category Plugin * @category Elasticsearch */ -export interface ElasticsearchQueryBuilderArgsPlugin< - T extends ContextInterface = ContextInterface -> { +export interface ElasticsearchQueryBuilderArgsPlugin { /** * A full path to the field. Including the ".keyword" if it is added. */ @@ -64,8 +62,4 @@ export interface ElasticsearchQueryBuilderArgsPlugin< * Is path containing the ".keyword" */ keyword: boolean; - /** - * Context we are working in. - */ - context: T; } diff --git a/packages/api-elasticsearch/src/where.ts b/packages/api-elasticsearch/src/where.ts new file mode 100644 index 00000000000..e9b1233d12f --- /dev/null +++ b/packages/api-elasticsearch/src/where.ts @@ -0,0 +1,100 @@ +import { ElasticsearchBoolQueryConfig } from "~/types"; +import { ElasticsearchFieldPlugin } from "~/plugins/definition/ElasticsearchFieldPlugin"; +import { ElasticsearchQueryBuilderOperatorPlugin } from "~/plugins/definition/ElasticsearchQueryBuilderOperatorPlugin"; +import WebinyError from "@webiny/error"; + +type Records = Record; + +export interface Params { + query: ElasticsearchBoolQueryConfig; + where: Records; + fields: Records; + operators: Records; +} + +export interface ParseWhereKeyResult { + field: string; + operator: string; +} + +const parseWhereKeyRegExp = new RegExp(/^([a-zA-Z0-9]+)(_[a-zA-Z0-9_]+)?$/); + +export const parseWhereKey = (key: string): ParseWhereKeyResult => { + const match = key.match(parseWhereKeyRegExp); + + if (!match) { + throw new Error(`It is not possible to search by key "${key}"`); + } + + const [, field, operation = "eq"] = match; + + if (!field.match(/^([a-zA-Z]+)$/)) { + throw new Error(`Cannot filter by "${field}".`); + } + + const operator = operation.match(/^_/) ? operation.substr(1) : operation; + + return { field, operator }; +}; + +const ALL = ElasticsearchFieldPlugin.ALL; + +export const applyWhere = (params: Params): void => { + const { query, where, fields, operators } = params; + + for (const key in where) { + if (where.hasOwnProperty(key) === false) { + continue; + } + const initialValue = where[key]; + /** + * There is a possibility that undefined is sent as a value, so just skip it. + */ + if (initialValue === undefined) { + continue; + } + const { field, operator } = parseWhereKey(key); + const fieldPlugin: ElasticsearchFieldPlugin = fields[field] || fields[ALL]; + if (!fieldPlugin) { + throw new WebinyError( + `Missing plugin for the field "${field}".`, + "PLUGIN_WHERE_ERROR", + { + field + } + ); + } + const operatorPlugin = operators[operator]; + if (!operatorPlugin) { + throw new WebinyError( + `Missing plugin for the operator "${operator}"`, + "PLUGIN_WHERE_ERROR", + { + operator + } + ); + } + + /** + * Get the path but in the case of * (all fields, replace * with the field. + * Custom path would return its own value anyways. + */ + const path = fieldPlugin.getPath(field); + const basePath = fieldPlugin.getBasePath(field); + /** + * Transform the value for the search. + */ + const value = fieldPlugin.toSearchValue({ + value: initialValue, + path, + basePath + }); + + operatorPlugin.apply(query, { + value, + path, + basePath, + keyword: fieldPlugin.keyword + }); + } +}; diff --git a/packages/api-file-manager-ddb-es/src/operations/configurations.ts b/packages/api-file-manager-ddb-es/src/operations/configurations.ts index 881c28a459c..c58cd2bfa1b 100644 --- a/packages/api-file-manager-ddb-es/src/operations/configurations.ts +++ b/packages/api-file-manager-ddb-es/src/operations/configurations.ts @@ -2,7 +2,7 @@ import { FileManagerContext } from "@webiny/api-file-manager/types"; export default { db: () => ({ - table: process.env.DB_TABLE_FILE_MANGER, + table: process.env.DB_TABLE_FILE_MANGER || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/api-file-manager-ddb-es/src/operations/files/body.ts b/packages/api-file-manager-ddb-es/src/operations/files/body.ts index 686a3294ffa..f2db878cb5e 100644 --- a/packages/api-file-manager-ddb-es/src/operations/files/body.ts +++ b/packages/api-file-manager-ddb-es/src/operations/files/body.ts @@ -1,4 +1,3 @@ -import WebinyError from "@webiny/error"; import { FileManagerContext, FileManagerFilesStorageOperationsListParamsWhere @@ -16,6 +15,7 @@ import { FileElasticsearchFieldPlugin } from "~/plugins/FileElasticsearchFieldPl import { FileElasticsearchSortModifierPlugin } from "~/plugins/FileElasticsearchSortModifierPlugin"; import { FileElasticsearchBodyModifierPlugin } from "~/plugins/FileElasticsearchBodyModifierPlugin"; import { FileElasticsearchQueryModifierPlugin } from "~/plugins/FileElasticsearchQueryModifierPlugin"; +import { applyWhere } from "@webiny/api-elasticsearch/where"; interface CreateElasticsearchBodyParams { context: FileManagerContext; @@ -25,51 +25,6 @@ interface CreateElasticsearchBodyParams { sort: string[]; } -const parseWhereKeyRegExp = new RegExp(/^([a-zA-Z0-9]+)(_[a-zA-Z0-9_]+)?$/); - -const parseWhereKey = (key: string) => { - const match = key.match(parseWhereKeyRegExp); - - if (!match) { - throw new Error(`It is not possible to search by key "${key}"`); - } - - const [, field, operation = "eq"] = match; - const op = operation.match(/^_/) ? operation.substr(1) : operation; - - if (!field.match(/^([a-zA-Z]+)$/)) { - throw new Error(`Cannot search by "${field}".`); - } - - return { field, op }; -}; - -const findFieldPlugin = ( - plugins: Record, - field: string -): FileElasticsearchFieldPlugin => { - const fieldPlugin = plugins[field] || plugins["*"]; - if (fieldPlugin) { - return fieldPlugin; - } - throw new WebinyError(`Missing plugin for the field "${field}".`, "PLUGIN_ERROR", { - field - }); -}; - -const findOperatorPlugin = ( - plugins: Record, - operator: string -): ElasticsearchQueryBuilderOperatorPlugin => { - const fieldPlugin = plugins[operator]; - if (fieldPlugin) { - return fieldPlugin; - } - throw new WebinyError(`Missing plugin for the operator "${operator}"`, "PLUGIN_ERROR", { - operator - }); -}; - const createElasticsearchQuery = ( params: CreateElasticsearchBodyParams & { plugins: Record; @@ -144,45 +99,12 @@ const createElasticsearchQuery = ( /** * We apply other conditions as they are passed via the where value. */ - for (const key in where) { - if (where.hasOwnProperty(key) === false) { - continue; - } - const initialValue = where[key]; - /** - * There is a possibility that undefined is sent as a value, so just skip it. - */ - if (initialValue === undefined) { - continue; - } - const { field, op } = parseWhereKey(key); - const fieldPlugin = findFieldPlugin(fieldPlugins, field); - const operatorPlugin = findOperatorPlugin(operatorPlugins, op); - - /** - * Get the path but in the case of * (all fields, replace * with the field. - * Custom path would return its own value anyways. - */ - const path = fieldPlugin.getPath(field); - const basePath = fieldPlugin.getBasePath(field); - /** - * Transform the value for the search. - */ - const value = fieldPlugin.toSearchValue({ - context, - value: initialValue, - path, - basePath - }); - - operatorPlugin.apply(query, { - context, - value, - path, - basePath: basePath, - keyword: fieldPlugin.keyword - }); - } + applyWhere({ + query, + where, + fields: fieldPlugins, + operators: operatorPlugins + }); return query; }; @@ -207,9 +129,8 @@ export const createElasticsearchBody = ( }); const sort = createSort({ - context, sort: initialSort, - plugins: fieldPlugins + fieldPlugins }); const queryModifiers = context.plugins.byType( @@ -217,7 +138,6 @@ export const createElasticsearchBody = ( ); for (const plugin of queryModifiers) { plugin.modifyQuery({ - context, query, where }); @@ -228,7 +148,6 @@ export const createElasticsearchBody = ( ); for (const plugin of sortModifiers) { plugin.modifySort({ - context, sort }); } @@ -258,7 +177,6 @@ export const createElasticsearchBody = ( ); for (const plugin of bodyModifiers) { plugin.modifyBody({ - context, body }); } diff --git a/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchBodyModifierPlugin.ts b/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchBodyModifierPlugin.ts index aec21391110..d954de78ddc 100644 --- a/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchBodyModifierPlugin.ts +++ b/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchBodyModifierPlugin.ts @@ -1,6 +1,5 @@ import { ElasticsearchBodyModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchBodyModifierPlugin"; -import { FileManagerContext } from "@webiny/api-file-manager/types"; -export class FileElasticsearchBodyModifierPlugin extends ElasticsearchBodyModifierPlugin { +export class FileElasticsearchBodyModifierPlugin extends ElasticsearchBodyModifierPlugin { public static readonly type: string = "fileManager.elasticsearch.modifier.body.file"; } diff --git a/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchQueryModifierPlugin.ts b/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchQueryModifierPlugin.ts index 7fd74aa317e..2c131e768fa 100644 --- a/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchQueryModifierPlugin.ts +++ b/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchQueryModifierPlugin.ts @@ -1,6 +1,5 @@ import { ElasticsearchQueryModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchQueryModifierPlugin"; -import { FileManagerContext } from "@webiny/api-file-manager/types"; -export class FileElasticsearchQueryModifierPlugin extends ElasticsearchQueryModifierPlugin { +export class FileElasticsearchQueryModifierPlugin extends ElasticsearchQueryModifierPlugin { public static readonly type: string = "fileManager.elasticsearch.modifier.query.file"; } diff --git a/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchSortModifierPlugin.ts b/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchSortModifierPlugin.ts index c1c920d64f3..d8f86e1e1d8 100644 --- a/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchSortModifierPlugin.ts +++ b/packages/api-file-manager-ddb-es/src/plugins/FileElasticsearchSortModifierPlugin.ts @@ -1,6 +1,5 @@ import { ElasticsearchSortModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchSortModifierPlugin"; -import { FileManagerContext } from "@webiny/api-file-manager/types"; -export class FileElasticsearchSortModifierPlugin extends ElasticsearchSortModifierPlugin { +export class FileElasticsearchSortModifierPlugin extends ElasticsearchSortModifierPlugin { public static readonly type: string = "fileManager.elasticsearch.modifier.sort.file"; } diff --git a/packages/api-file-manager-ddb/src/operations/configurations.ts b/packages/api-file-manager-ddb/src/operations/configurations.ts index 9d73171b519..8cbe1fdb528 100644 --- a/packages/api-file-manager-ddb/src/operations/configurations.ts +++ b/packages/api-file-manager-ddb/src/operations/configurations.ts @@ -1,6 +1,6 @@ export default { db: () => ({ - table: process.env.DB_TABLE_FILE_MANGER, + table: process.env.DB_TABLE_FILE_MANGER || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/api-file-manager/__tests__/filesSettings.test.ts b/packages/api-file-manager/__tests__/filesSettings.test.ts index 489282c43f4..50226371c7f 100644 --- a/packages/api-file-manager/__tests__/filesSettings.test.ts +++ b/packages/api-file-manager/__tests__/filesSettings.test.ts @@ -15,8 +15,8 @@ describe("Files settings test", () => { }); test("install File manager", async () => { - let [response] = await isInstalled({}); - expect(response).toEqual({ + const [isInstalledResponse] = await isInstalled({}); + expect(isInstalledResponse).toEqual({ data: { fileManager: { version: null @@ -24,10 +24,10 @@ describe("Files settings test", () => { } }); - [response] = await install({ + const [installResponse] = await install({ srcPrefix: "https://0c6fb883-webiny-latest-files.s3.amazonaws.com/" }); - expect(response).toEqual({ + expect(installResponse).toEqual({ data: { fileManager: { install: { @@ -38,8 +38,8 @@ describe("Files settings test", () => { } }); - [response] = await isInstalled({}); - expect(response).toEqual({ + const [afterInstallIsInstalledResponse] = await isInstalled({}); + expect(afterInstallIsInstalledResponse).toEqual({ data: { fileManager: { version: expect.any(String) @@ -65,8 +65,8 @@ describe("Files settings test", () => { } }); - let [response] = await getSettings(); - expect(response).toEqual({ + const [getSettingsResponse] = await getSettings(); + expect(getSettingsResponse).toEqual({ data: { fileManager: { getSettings: { @@ -80,8 +80,10 @@ describe("Files settings test", () => { } }); - [response] = await updateSettings({ data: { uploadMinFileSize: -1111 } }); - expect(response).toEqual({ + const [updateInvalidMinFileSize] = await updateSettings({ + data: { uploadMinFileSize: -1111 } + }); + expect(updateInvalidMinFileSize).toEqual({ data: { fileManager: { updateSettings: { @@ -104,10 +106,10 @@ describe("Files settings test", () => { } }); - [response] = await updateSettings({ + const [updateMinFileSizeSettingsReponse] = await updateSettings({ data: { uploadMinFileSize: 1024 } }); - expect(response).toEqual({ + expect(updateMinFileSizeSettingsReponse).toEqual({ data: { fileManager: { updateSettings: { @@ -121,8 +123,8 @@ describe("Files settings test", () => { } }); - [response] = await getSettings({}); - expect(response).toEqual({ + const [getSettingsAfterUpdateResponse] = await getSettings({}); + expect(getSettingsAfterUpdateResponse).toEqual({ data: { fileManager: { getSettings: { diff --git a/packages/api-file-manager/__tests__/useGqlHandler.ts b/packages/api-file-manager/__tests__/useGqlHandler.ts index 57a8f10ee50..5c72cfcba0f 100644 --- a/packages/api-file-manager/__tests__/useGqlHandler.ts +++ b/packages/api-file-manager/__tests__/useGqlHandler.ts @@ -29,11 +29,11 @@ import { SecurityPermission } from "@webiny/api-security/types"; import { until } from "./helpers"; import { FilePhysicalStoragePlugin } from "~/plugins/definitions/FilePhysicalStoragePlugin"; -type UseGqlHandlerParams = { +export interface UseGqlHandlerParams { permissions?: SecurityPermission[]; identity?: SecurityIdentity; plugins?: any; -}; +} export default (params?: UseGqlHandlerParams) => { const { permissions, identity, plugins = [] } = params; diff --git a/packages/api-form-builder-so-ddb-es/.babelrc.js b/packages/api-form-builder-so-ddb-es/.babelrc.js new file mode 100644 index 00000000000..7cdc243c30a --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("../../.babel.node")({ path: __dirname }); diff --git a/packages/api-form-builder-so-ddb-es/CHANGELOG.md b/packages/api-form-builder-so-ddb-es/CHANGELOG.md new file mode 100644 index 00000000000..e4d87c4d45c --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/api-form-builder-so-ddb-es/LICENSE b/packages/api-form-builder-so-ddb-es/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/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-form-builder-so-ddb-es/README.md b/packages/api-form-builder-so-ddb-es/README.md new file mode 100644 index 00000000000..1b25a26a18c --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/README.md @@ -0,0 +1,17 @@ +# @webiny/api-form-builder-so-ddb-es +[![](https://img.shields.io/npm/dw/@webiny/api-form-builder-so-ddb-es.svg)](https://www.npmjs.com/package/@webiny/api-form-builder-so-ddb-es) +[![](https://img.shields.io/npm/v/@webiny/api-form-builder-so-ddb-es.svg)](https://www.npmjs.com/package/@webiny/api-form-builder-so-ddb-es) +[![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) + +The API for the [Webiny Forms (@webiny/app-form-builder-so-ddb-es)](../app-form-builder-so-ddb-es) app. + +## Install +``` +npm install --save @webiny/api-form-builder-so-ddb-es +``` + +Or if you prefer yarn: +``` +yarn add @webiny/api-form-builder-so-ddb-es +``` diff --git a/packages/api-form-builder-so-ddb-es/__tests__/__api__/environment.js b/packages/api-form-builder-so-ddb-es/__tests__/__api__/environment.js new file mode 100644 index 00000000000..d70ae4357fc --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/__tests__/__api__/environment.js @@ -0,0 +1,104 @@ +const dbPlugins = require("@webiny/handler-db").default; +const { DynamoDbDriver } = require("@webiny/db-dynamodb"); +const { DocumentClient } = require("aws-sdk/clients/dynamodb"); +const createElasticsearchClientContextPlugin = require("@webiny/api-elasticsearch").default; +const { createHandler } = require("@webiny/handler-aws"); +const dynamoToElastic = require("@webiny/api-dynamodb-to-elasticsearch/handler").default; +const { simulateStream } = require("@webiny/project-utils/testing/dynamodb"); +const NodeEnvironment = require("jest-environment-node"); +const elasticsearchDataGzipCompression = + require("@webiny/api-elasticsearch/plugins/GzipCompression").default; +const { ContextPlugin } = require("@webiny/handler/plugins/ContextPlugin"); +const dynamoDbPlugins = require("@webiny/db-dynamodb/plugins").default; +const { createElasticsearchClient } = require("@webiny/api-elasticsearch/client"); +const { getElasticsearchOperators } = require("@webiny/api-elasticsearch/operators"); +/** + * For this to work it must load plugins that have already been built + */ +const { createFormBuilderStorageOperations } = require("../../dist/index"); + +if (typeof createFormBuilderStorageOperations !== "function") { + throw new Error( + `Loaded "createFormBuilderStorageOperations" must be a function that will return the storage operations.` + ); +} + +const ELASTICSEARCH_PORT = process.env.ELASTICSEARCH_PORT || "9200"; + +class FormBuilderTestEnvironment extends NodeEnvironment { + async setup() { + await super.setup(); + + const elasticsearchClient = createElasticsearchClient({ + node: `http://localhost:${ELASTICSEARCH_PORT}` + }); + const documentClient = new DocumentClient({ + convertEmptyValues: true, + endpoint: process.env.MOCK_DYNAMODB_ENDPOINT || "http://localhost:8001", + sslEnabled: false, + region: "local", + accessKeyId: "test", + secretAccessKey: "test" + }); + const elasticsearchClientContext = createElasticsearchClientContextPlugin({ + endpoint: `http://localhost:${ELASTICSEARCH_PORT}`, + auth: {} + }); + + /** + * Intercept DocumentClient operations and trigger dynamoToElastic function (almost like a DynamoDB Stream trigger) + */ + const simulationContext = new ContextPlugin(async context => { + context.plugins.register([elasticsearchDataGzipCompression()]); + await elasticsearchClientContext.apply(context); + }); + simulateStream(documentClient, createHandler(simulationContext, dynamoToElastic())); + + const clearEsIndices = async () => { + return elasticsearchClient.indices.delete({ + index: "_all" + }); + }; + /** + * This is a global function that will be called inside the tests to get all relevant plugins, methods and objects. + */ + this.global.__getStorageOperations = () => { + return { + createStorageOperations: () => { + return createFormBuilderStorageOperations({ + table: "DynamoDB", + esTable: "ElasticSearchStream", + documentClient, + // TODO need to insert elasticsearch client + elasticsearch: elasticsearchClient, + plugins: [ + ...dynamoDbPlugins(), + elasticsearchDataGzipCompression(), + ...getElasticsearchOperators() + ] + }); + }, + getGlobalPlugins: () => { + return [ + elasticsearchClientContext, + ...dbPlugins({ + table: "DynamoDB", + driver: new DynamoDbDriver({ + documentClient + }) + }), + ...dynamoDbPlugins(), + elasticsearchDataGzipCompression(), + ...getElasticsearchOperators() + ]; + } + }; + }; + this.global.__beforeEach = clearEsIndices; + this.global.__afterEach = clearEsIndices; + this.global.__beforeAll = clearEsIndices; + this.global.__afterAll = clearEsIndices; + } +} + +module.exports = FormBuilderTestEnvironment; diff --git a/packages/api-form-builder-so-ddb-es/__tests__/__api__/presets.js b/packages/api-form-builder-so-ddb-es/__tests__/__api__/presets.js new file mode 100644 index 00000000000..40c002e83d3 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/__tests__/__api__/presets.js @@ -0,0 +1,15 @@ +const path = require("path"); +const os = require("os"); +const esPreset = os.platform() === "win32" ? {} : require("@shelf/jest-elasticsearch/jest-preset"); + +const isLocalElastic = !!process.env.LOCAL_ELASTICSEARCH; + +const presets = [ + isLocalElastic ? {} : esPreset, + { + testEnvironment: path.resolve(__dirname, "environment.js"), + setupFilesAfterEnv: [path.resolve(__dirname, "setupAfterEnv.js")] + } +]; + +module.exports = presets; diff --git a/packages/api-form-builder-so-ddb-es/__tests__/__api__/setupAfterEnv.js b/packages/api-form-builder-so-ddb-es/__tests__/__api__/setupAfterEnv.js new file mode 100644 index 00000000000..b8857b963d9 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/__tests__/__api__/setupAfterEnv.js @@ -0,0 +1,28 @@ +const jestDynalite = require("jest-dynalite"); +const path = require("path"); +/** + * Must be a root of this package. + */ +jestDynalite.setup(path.resolve(__dirname, "../../")); +/** + * Assign all required dynalite lifecycle methods. + * And add custom lifecycle methods that are defined in the environment.js global + */ +beforeAll(async () => { + await jestDynalite.startDb(); + //await __beforeAll(); +}); + +beforeEach(async () => { + await jestDynalite.createTables(); + await __beforeEach(); +}); +afterEach(async () => { + await jestDynalite.deleteTables(); + await __afterEach(); +}); + +afterAll(async () => { + await jestDynalite.stopDb(); + //await __afterAll(); +}); diff --git a/packages/api-form-builder/jest-dynalite-config.js b/packages/api-form-builder-so-ddb-es/jest-dynalite-config.js similarity index 96% rename from packages/api-form-builder/jest-dynalite-config.js rename to packages/api-form-builder-so-ddb-es/jest-dynalite-config.js index 440d617917d..b937ada8d2f 100644 --- a/packages/api-form-builder/jest-dynalite-config.js +++ b/packages/api-form-builder-so-ddb-es/jest-dynalite-config.js @@ -1,7 +1,7 @@ module.exports = { tables: [ { - TableName: `FormBuilder`, + TableName: `DynamoDB`, KeySchema: [ { AttributeName: "PK", KeyType: "HASH" }, { AttributeName: "SK", KeyType: "RANGE" } diff --git a/packages/api-form-builder/jest.config.js b/packages/api-form-builder-so-ddb-es/jest.config.js similarity index 100% rename from packages/api-form-builder/jest.config.js rename to packages/api-form-builder-so-ddb-es/jest.config.js diff --git a/packages/api-form-builder-so-ddb-es/package.json b/packages/api-form-builder-so-ddb-es/package.json new file mode 100644 index 00000000000..fee1083e6ec --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/package.json @@ -0,0 +1,67 @@ +{ + "name": "@webiny/api-form-builder-so-ddb-es", + "version": "5.15.0", + "main": "index.js", + "keywords": [ + "@webiny/api-form-builder", + "storage-operations", + "dynamodb", + "elasticsearch", + "fb:ddb-es" + ], + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git", + "directory": "packages/api-form-builder-so-ddb-es" + }, + "contributors": [ + "Pavel Denisjuk ", + "Sven Al Hamad ", + "Adrian Smijulj " + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@elastic/elasticsearch": "7.12.0", + "@webiny/api-elasticsearch": "^5.15.0", + "@webiny/api-form-builder": "^5.15.0", + "@webiny/api-i18n": "^5.15.0", + "@webiny/api-tenancy": "^5.15.0", + "@webiny/api-upgrade": "^5.15.0", + "@webiny/db-dynamodb": "^5.15.0", + "@webiny/error": "^5.15.0", + "@webiny/handler": "^5.15.0", + "@webiny/handler-aws": "^5.15.0", + "@webiny/plugins": "^5.15.0", + "@webiny/utils": "^5.15.0", + "dynamodb-toolbox": "^0.3.4", + "elastic-ts": "^0.7.0" + }, + "devDependencies": { + "@babel/cli": "^7.5.5", + "@babel/core": "^7.5.5", + "@babel/preset-env": "^7.5.5", + "@babel/preset-typescript": "^7.8.3", + "@shelf/jest-elasticsearch": "^1.0.0", + "@webiny/api-dynamodb-to-elasticsearch": "^5.15.0", + "@webiny/cli": "^5.15.0", + "@webiny/handler-db": "^5.15.0", + "@webiny/project-utils": "^5.15.0", + "csvtojson": "^2.0.10", + "jest": "^26.6.3", + "jest-dynalite": "^3.2.0", + "jest-environment-node": "^27.2.4", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.12", + "typescript": "^4.1.3" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "b8aec8a1be3f25c3b428b357fe1e352c7cbff9ae" +} diff --git a/packages/api-form-builder-so-ddb-es/src/configurations.ts b/packages/api-form-builder-so-ddb-es/src/configurations.ts new file mode 100644 index 00000000000..cc08f03e8b4 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/configurations.ts @@ -0,0 +1,18 @@ +export interface ElasticsearchConfigParams { + tenant: string; +} + +export default { + es(params: ElasticsearchConfigParams) { + const { tenant } = params; + + const sharedIndex = process.env.ELASTICSEARCH_SHARED_INDEXES === "true"; + const index = `${sharedIndex ? "root" : tenant}-form-builder`; + + const prefix = process.env.ELASTIC_SEARCH_INDEX_PREFIX; + if (prefix) { + return { index: prefix + index }; + } + return { index }; + } +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts b/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts new file mode 100644 index 00000000000..9814b5f3537 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts @@ -0,0 +1,35 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +export interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createElasticsearchEntity = (params: Params) => { + const { table, entityName, attributes } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + index: { + type: "string" + }, + data: { + type: "map" + }, + TYPE: { + type: "string" + }, + + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/form.ts b/packages/api-form-builder-so-ddb-es/src/definitions/form.ts new file mode 100644 index 00000000000..4ced4ea77a4 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/form.ts @@ -0,0 +1,91 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +export interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createFormEntity = (params: Params): Entity => { + const { table, entityName, attributes } = params; + return new Entity({ + table, + name: entityName, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + TYPE: { + type: "string" + }, + id: { + type: "string" + }, + formId: { + type: "string" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + createdBy: { + type: "map" + }, + ownedBy: { + type: "map" + }, + savedOn: { + type: "string" + }, + createdOn: { + type: "string" + }, + name: { + type: "string" + }, + slug: { + type: "string" + }, + version: { + type: "number" + }, + locked: { + type: "boolean" + }, + published: { + type: "boolean" + }, + publishedOn: { + type: "string" + }, + status: { + type: "string" + }, + fields: { + type: "list" + }, + layout: { + type: "list" + }, + stats: { + type: "map" + }, + settings: { + type: "map" + }, + triggers: { + type: "map" + }, + webinyVersion: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/settings.ts b/packages/api-form-builder-so-ddb-es/src/definitions/settings.ts new file mode 100644 index 00000000000..e0f609f24a4 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/settings.ts @@ -0,0 +1,40 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +export interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createSettingsEntity = (params: Params): Entity => { + const { entityName, attributes, table } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + TYPE: { + type: "string" + }, + reCaptcha: { + type: "map" + }, + domain: { + type: "string" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/submission.ts b/packages/api-form-builder-so-ddb-es/src/definitions/submission.ts new file mode 100644 index 00000000000..1beb239870a --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/submission.ts @@ -0,0 +1,61 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +export interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createSubmissionEntity = (params: Params): Entity => { + const { table, entityName, attributes } = params; + return new Entity({ + table, + name: entityName, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + id: { + type: "string" + }, + TYPE: { + type: "string" + }, + data: { + type: "map" + }, + meta: { + type: "map" + }, + form: { + type: "map" + }, + logs: { + type: "list" + }, + createdOn: { + type: "string" + }, + savedOn: { + type: "string" + }, + ownedBy: { + type: "map" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + webinyVersion: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/system.ts b/packages/api-form-builder-so-ddb-es/src/definitions/system.ts new file mode 100644 index 00000000000..41b6e68d6f9 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/system.ts @@ -0,0 +1,31 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +export interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createSystemEntity = (params: Params): Entity => { + const { entityName, attributes, table } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + version: { + type: "string" + }, + tenant: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/table.ts b/packages/api-form-builder-so-ddb-es/src/definitions/table.ts new file mode 100644 index 00000000000..486b95cfee7 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/table.ts @@ -0,0 +1,18 @@ +import { DocumentClient } from "aws-sdk/clients/dynamodb"; +import { Table } from "dynamodb-toolbox"; + +export interface Params { + tableName: string; + documentClient: DocumentClient; +} + +export const createTable = (params: Params): Table => { + const { tableName, documentClient } = params; + + return new Table({ + name: tableName || process.env.DB_TABLE_FORM_BUILDER || process.env.DB_TABLE, + partitionKey: "PK", + sortKey: "SK", + DocumentClient: documentClient + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/tableElasticsearch.ts b/packages/api-form-builder-so-ddb-es/src/definitions/tableElasticsearch.ts new file mode 100644 index 00000000000..3c1d8513043 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/definitions/tableElasticsearch.ts @@ -0,0 +1,17 @@ +import { Table } from "dynamodb-toolbox"; +import { DocumentClient } from "aws-sdk/clients/dynamodb"; + +export interface Params { + documentClient: DocumentClient; + tableName?: string; +} + +export const createElasticsearchTable = (params: Params): Table => { + const { tableName, documentClient } = params; + return new Table({ + name: tableName || process.env.DB_TABLE_ELASTICSEARCH, + partitionKey: "PK", + sortKey: "SK", + DocumentClient: documentClient + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/index.ts b/packages/api-form-builder-so-ddb-es/src/index.ts new file mode 100644 index 00000000000..6b9755c85d5 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/index.ts @@ -0,0 +1,158 @@ +import { FormBuilderStorageOperationsFactory, ENTITIES } from "~/types"; +import WebinyError from "@webiny/error"; +import { createTable } from "~/definitions/table"; +import { createFormEntity } from "~/definitions/form"; +import { createSubmissionEntity } from "~/definitions/submission"; +import { createSystemEntity } from "~/definitions/system"; +import { createSettingsEntity } from "~/definitions/settings"; +import { createSystemStorageOperations } from "~/operations/system"; +import { createSubmissionStorageOperations } from "~/operations/submission"; +import { createSettingsStorageOperations } from "~/operations/settings"; +import { createFormStorageOperations } from "~/operations/form"; +import { createElasticsearchIndex } from "~/operations/system/createElasticsearchIndex"; +import { createElasticsearchTable } from "~/definitions/tableElasticsearch"; +import { PluginsContainer } from "@webiny/plugins"; +import { createElasticsearchEntity } from "~/definitions/elasticsearch"; +import submissionElasticsearchFields from "./operations/submission/elasticsearchFields"; +import formElasticsearchFields from "./operations/form/elasticsearchFields"; +import dynamoDbValueFilters from "@webiny/db-dynamodb/plugins/filters"; +import { getElasticsearchOperators } from "@webiny/api-elasticsearch/operators"; + +import upgrade5160 from "./upgrades/5.16.0"; + +const reservedFields = ["PK", "SK", "index", "data", "TYPE", "__type", "GSI1_PK", "GSI1_SK"]; + +const isReserved = (name: string): void => { + if (reservedFields.includes(name) === false) { + return; + } + throw new WebinyError(`Attribute name "${name}" is not allowed.`, "ATTRIBUTE_NOT_ALLOWED", { + name + }); +}; + +export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFactory = params => { + const { + attributes = {}, + table: tableName, + esTable: esTableName, + documentClient, + elasticsearch, + plugins: pluginsInput + } = params; + + if (attributes) { + Object.values(attributes).forEach(attrs => { + Object.keys(attrs).forEach(isReserved); + }); + } + + const plugins = new PluginsContainer([ + /** + * User defined plugins. + */ + pluginsInput || [], + /** + * Elasticsearch field definitions for the submission record. + */ + submissionElasticsearchFields(), + /** + * Elasticsearch field definitions for the form record. + */ + formElasticsearchFields(), + /** + * DynamoDB filter plugins for the where conditions. + */ + dynamoDbValueFilters(), + /** + * Elasticsearch operators. + */ + getElasticsearchOperators() + ]); + + const table = createTable({ + tableName, + documentClient + }); + + const esTable = createElasticsearchTable({ + tableName: esTableName, + documentClient + }); + + const entities = { + /** + * Regular entities. + */ + form: createFormEntity({ + entityName: ENTITIES.FORM, + table, + attributes: attributes[ENTITIES.FORM] + }), + submission: createSubmissionEntity({ + entityName: ENTITIES.SUBMISSION, + table, + attributes: attributes[ENTITIES.SUBMISSION] + }), + system: createSystemEntity({ + entityName: ENTITIES.SYSTEM, + table, + attributes: attributes[ENTITIES.SYSTEM] + }), + settings: createSettingsEntity({ + entityName: ENTITIES.SETTINGS, + table, + attributes: attributes[ENTITIES.SETTINGS] + }), + /** + * Elasticsearch entities. + */ + esForm: createElasticsearchEntity({ + entityName: ENTITIES.ES_FORM, + table: esTable, + attributes: attributes[ENTITIES.ES_FORM] + }), + esSubmission: createElasticsearchEntity({ + entityName: ENTITIES.ES_SUBMISSION, + table: esTable, + attributes: attributes[ENTITIES.ES_SUBMISSION] + }) + }; + + return { + init: async formBuilder => { + formBuilder.onAfterInstall.subscribe(async ({ tenant }) => { + await createElasticsearchIndex({ + elasticsearch, + tenant + }); + }); + }, + upgrade: upgrade5160(), + getTable: () => table, + getEsTable: () => esTable, + getEntities: () => entities, + ...createSystemStorageOperations({ + table, + entity: entities.system + }), + ...createSettingsStorageOperations({ + table, + entity: entities.settings + }), + ...createFormStorageOperations({ + elasticsearch, + table, + entity: entities.form, + esEntity: entities.esForm, + plugins + }), + ...createSubmissionStorageOperations({ + elasticsearch, + table, + entity: entities.submission, + esEntity: entities.esSubmission, + plugins + }) + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchBody.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchBody.ts new file mode 100644 index 00000000000..ee80a243785 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchBody.ts @@ -0,0 +1,182 @@ +import { SearchBody as esSearchBody } from "elastic-ts"; +import { decodeCursor } from "@webiny/api-elasticsearch/cursors"; +import { ElasticsearchBoolQueryConfig } from "@webiny/api-elasticsearch/types"; +import { createSort } from "@webiny/api-elasticsearch/sort"; +import { createLimit } from "@webiny/api-elasticsearch/limit"; +import { ElasticsearchQueryBuilderOperatorPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchQueryBuilderOperatorPlugin"; +import { FormElasticsearchFieldPlugin } from "~/plugins/FormElasticsearchFieldPlugin"; +import { FormElasticsearchSortModifierPlugin } from "~/plugins/FormElasticsearchSortModifierPlugin"; +import { FormElasticsearchBodyModifierPlugin } from "~/plugins/FormElasticsearchBodyModifierPlugin"; +import { FormBuilderStorageOperationsListFormsParams } from "@webiny/api-form-builder/types"; +import { FormElasticsearchQueryModifierPlugin } from "~/plugins/FormElasticsearchQueryModifierPlugin"; +import { PluginsContainer } from "@webiny/plugins"; +import { applyWhere } from "@webiny/api-elasticsearch/where"; + +export const createFormElasticType = (): string => { + return "fb.form"; +}; + +const createInitialQueryValue = (): ElasticsearchBoolQueryConfig => { + return { + must: [ + /** + * We add the __type filtering in the initial query because it must be applied. + */ + { + term: { + "__type.keyword": createFormElasticType() + } + } + ], + must_not: [], + should: [], + filter: [] + }; +}; + +interface CreateElasticsearchQueryParams extends CreateElasticsearchBodyParams { + fieldPlugins: Record; +} + +const createElasticsearchQuery = (params: CreateElasticsearchQueryParams) => { + const { plugins, where: initialWhere, fieldPlugins } = params; + const query = createInitialQueryValue(); + /** + * Be aware that, if having more registered operator plugins of same type, the last one will be used. + */ + const operatorPlugins: Record = plugins + .byType( + ElasticsearchQueryBuilderOperatorPlugin.type + ) + .reduce((acc, plugin) => { + acc[plugin.getOperator()] = plugin; + return acc; + }, {}); + + const where: FormBuilderStorageOperationsListFormsParams["where"] = { + ...initialWhere + }; + /** + * !!! IMPORTANT !!! There are few specific cases where we hardcode the query conditions. + * + * When ES index is shared between tenants, we need to filter records by tenant ID. + * No need for the tenant filtering otherwise as each index is for single tenant. + */ + const sharedIndex = process.env.ELASTICSEARCH_SHARED_INDEXES === "true"; + if (sharedIndex) { + query.must.push({ + term: { + "tenant.keyword": where.tenant + } + }); + } + /** + * Remove tenant so it is not applied again later. + * Possibly tenant is not defined, but just in case, remove it. + */ + delete where.tenant; + /** + * Add the locale to filtering. + */ + query.must.push({ + term: { + "locale.keyword": where.locale + } + }); + delete where.locale; + /** + * We apply other conditions as they are passed via the where value. + */ + applyWhere({ + query, + where, + fields: fieldPlugins, + operators: operatorPlugins + }); + + return query; +}; + +interface CreateElasticsearchBodyParams { + plugins: PluginsContainer; + where: FormBuilderStorageOperationsListFormsParams["where"]; + limit: number; + after?: string; + sort: string[]; +} + +export const createElasticsearchBody = (params: CreateElasticsearchBodyParams): esSearchBody => { + const { plugins, where, limit: initialLimit, sort: initialSort, after } = params; + + const fieldPlugins: Record = plugins + .byType(FormElasticsearchFieldPlugin.type) + .reduce((acc, plugin) => { + acc[plugin.field] = plugin; + return acc; + }, {}); + + const limit = createLimit(initialLimit, 100); + + const query = createElasticsearchQuery({ + ...params, + fieldPlugins + }); + + const sort = createSort({ + sort: initialSort, + fieldPlugins + }); + + const queryModifiers = plugins.byType( + FormElasticsearchQueryModifierPlugin.type + ); + + for (const plugin of queryModifiers) { + plugin.modifyQuery({ + query, + where + }); + } + + const sortModifiers = plugins.byType( + FormElasticsearchSortModifierPlugin.type + ); + + for (const plugin of sortModifiers) { + plugin.modifySort({ + sort + }); + } + + const body = { + query: { + constant_score: { + filter: { + bool: { + ...query + } + } + } + }, + size: limit + 1, + /** + * Casting as any is required due to search_after is accepting an array of values. + * Which is correct in some cases. In our case, it is not. + * https://www.elastic.co/guide/en/elasticsearch/reference/7.13/paginate-search-results.html + */ + search_after: decodeCursor(after) as any, + sort + }; + + const bodyModifiers = plugins.byType( + FormElasticsearchBodyModifierPlugin.type + ); + + for (const plugin of bodyModifiers) { + plugin.modifyBody({ + body + }); + } + + return body; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchFields.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchFields.ts new file mode 100644 index 00000000000..81e904dbae6 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/elasticsearchFields.ts @@ -0,0 +1,26 @@ +import { FormElasticsearchFieldPlugin } from "~/plugins/FormElasticsearchFieldPlugin"; + +export default () => [ + new FormElasticsearchFieldPlugin({ + field: "createdOn", + unmappedType: "date" + }), + new FormElasticsearchFieldPlugin({ + field: "savedOn", + unmappedType: "date" + }), + new FormElasticsearchFieldPlugin({ + field: "publishedOn", + unmappedType: "date" + }), + new FormElasticsearchFieldPlugin({ + field: "ownedBy", + path: "ownedBy.id" + }), + /** + * Always add the ALL fields plugin because of the keyword/path build. + */ + new FormElasticsearchFieldPlugin({ + field: FormElasticsearchFieldPlugin.ALL + }) +]; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/fields.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/fields.ts new file mode 100644 index 00000000000..ad06f53033f --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/fields.ts @@ -0,0 +1,8 @@ +import { FormDynamoDbFieldPlugin } from "~/plugins/FormDynamoDbFieldPlugin"; + +export default () => [ + new FormDynamoDbFieldPlugin({ + field: "publishedOn", + type: "date" + }) +]; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts new file mode 100644 index 00000000000..bcaf8202d7b --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts @@ -0,0 +1,973 @@ +import { + FbForm, + FormBuilderStorageOperationsCreateFormFromParams, + FormBuilderStorageOperationsCreateFormParams, + FormBuilderStorageOperationsDeleteFormParams, + FormBuilderStorageOperationsDeleteFormRevisionParams, + FormBuilderStorageOperationsGetFormParams, + FormBuilderStorageOperationsListFormRevisionsParams, + FormBuilderStorageOperationsListFormsParams, + FormBuilderStorageOperationsListFormsResponse, + FormBuilderStorageOperationsPublishFormParams, + FormBuilderStorageOperationsUnpublishFormParams, + FormBuilderStorageOperationsUpdateFormParams +} from "@webiny/api-form-builder/types"; +import { Entity, Table } from "dynamodb-toolbox"; +import { Client } from "@elastic/elasticsearch"; +import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; +import WebinyError from "@webiny/error"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; +import configurations from "~/configurations"; +import { filterItems } from "@webiny/db-dynamodb/utils/filter"; +import fields from "./fields"; +import { sortItems } from "@webiny/db-dynamodb/utils/sort"; +import { parseIdentifier, zeroPad } from "@webiny/utils"; +import { createElasticsearchBody, createFormElasticType } from "./elasticsearchBody"; +import { decodeCursor, encodeCursor } from "@webiny/api-elasticsearch/cursors"; +import { PluginsContainer } from "@webiny/plugins"; +import { FormBuilderFormCreateKeyParams, FormBuilderFormStorageOperations } from "~/types"; + +export type DbRecord = T & { + PK: string; + SK: string; + TYPE: string; +}; + +export interface Params { + entity: Entity; + esEntity: Entity; + table: Table; + elasticsearch: Client; + plugins: PluginsContainer; +} + +type FbFormElastic = Omit & { + __type: string; +}; + +const getESDataForLatestRevision = (form: FbForm): FbFormElastic => ({ + __type: createFormElasticType(), + id: form.id, + createdOn: form.createdOn, + savedOn: form.savedOn, + name: form.name, + slug: form.slug, + published: form.published, + publishedOn: form.publishedOn, + version: form.version, + locked: form.locked, + status: form.status, + createdBy: form.createdBy, + ownedBy: form.ownedBy, + tenant: form.tenant, + locale: form.locale, + webinyVersion: form.webinyVersion, + formId: form.formId +}); + +export const createFormStorageOperations = (params: Params): FormBuilderFormStorageOperations => { + const { entity, esEntity, table, plugins, elasticsearch } = params; + + const formDynamoDbFields = fields(); + + const createFormPartitionKey = (params: FormBuilderFormCreateKeyParams): string => { + const { tenant, locale, id: targetId } = params; + + const { id } = parseIdentifier(targetId); + + return `T#${tenant}#L#${locale}#FB#F#${id}`; + }; + + const createRevisionSortKey = (value: string | number): string => { + const version = typeof value === "number" ? Number(value) : parseIdentifier(value).version; + return `REV#${zeroPad(version)}`; + }; + + const createLatestSortKey = (): string => { + return "L"; + }; + + const createLatestPublishedSortKey = (): string => { + return "LP"; + }; + + const createFormType = (): string => { + return "fb.form"; + }; + + const createFormLatestType = (): string => { + return "fb.form.latest"; + }; + + const createFormLatestPublishedType = (): string => { + return "fb.form.latestPublished"; + }; + + const createForm = async ( + params: FormBuilderStorageOperationsCreateFormParams + ): Promise => { + const { form } = params; + + const revisionKeys = { + PK: createFormPartitionKey(form), + SK: createRevisionSortKey(form.id) + }; + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + + const items = [ + entity.putBatch({ + ...form, + TYPE: createFormType(), + ...revisionKeys + }), + entity.putBatch({ + ...form, + TYPE: createFormLatestType(), + ...latestKeys + }) + ]; + + try { + await batchWriteAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not insert form data into regular table.", + ex.code || "CREATE_FORM_ERROR", + { + revisionKeys, + latestKeys, + form + } + ); + } + try { + const { index } = configurations.es({ + tenant: form.tenant + }); + await esEntity.put({ + index, + data: getESDataForLatestRevision(form), + TYPE: createFormType(), + ...latestKeys + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not insert form data into Elasticsearch table.", + ex.code || "CREATE_FORM_ERROR", + { + latestKeys, + form + } + ); + } + return form; + }; + + const createFormFrom = async ( + params: FormBuilderStorageOperationsCreateFormFromParams + ): Promise => { + const { form, original, latest } = params; + + const revisionKeys = { + PK: createFormPartitionKey(form), + SK: createRevisionSortKey(form.version) + }; + + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + + const items = [ + entity.putBatch({ + ...form, + ...revisionKeys, + TYPE: createFormType() + }), + entity.putBatch({ + ...form, + ...latestKeys, + TYPE: createFormLatestType() + }) + ]; + + try { + await batchWriteAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || + "Could not create form data in the regular table, from existing form.", + ex.code || "CREATE_FORM_FROM_ERROR", + { + revisionKeys, + latestKeys, + original, + form, + latest + } + ); + } + + try { + const { index } = configurations.es({ + tenant: form.tenant + }); + await esEntity.put({ + index, + data: getESDataForLatestRevision(form), + TYPE: createFormLatestType(), + ...latestKeys + }); + } catch (ex) { + throw new WebinyError( + ex.message || + "Could not create form in the Elasticsearch table, from existing form.", + ex.code || "CREATE_FORM_FROM_ERROR", + { + latestKeys, + form, + latest, + original + } + ); + } + return form; + }; + + const updateForm = async ( + params: FormBuilderStorageOperationsUpdateFormParams + ): Promise => { + const { form, original } = params; + + const revisionKeys = { + PK: createFormPartitionKey(form), + SK: createRevisionSortKey(form.id) + }; + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + + const { formId, tenant, locale } = form; + + const latestForm = await getForm({ + where: { + formId, + tenant, + locale, + latest: true + } + }); + const isLatestForm = latestForm ? latestForm.id === form.id : false; + + const items = [ + entity.putBatch({ + ...form, + TYPE: createFormType(), + ...revisionKeys + }) + ]; + if (isLatestForm) { + items.push( + entity.putBatch({ + ...form, + TYPE: createLatestSortKey(), + ...latestKeys + }) + ); + } + try { + await batchWriteAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form data in the regular table.", + ex.code || "UPDATE_FORM_ERROR", + { + revisionKeys, + latestKeys, + original, + form, + latestForm + } + ); + } + /** + * No need to go further if its not latest form. + */ + if (!isLatestForm) { + return form; + } + + try { + const { index } = configurations.es({ + tenant: form.tenant + }); + await esEntity.put({ + index, + data: getESDataForLatestRevision(form), + TYPE: createFormLatestType(), + ...latestKeys + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form data in the Elasticsearch table.", + ex.code || "UPDATE_FORM_ERROR", + { + latestKeys, + form, + latestForm, + original + } + ); + } + return form; + }; + + const getForm = async (params: FormBuilderStorageOperationsGetFormParams): Promise => { + const { where } = params; + const { id, formId, latest, published, version, tenant, locale } = where; + if (latest && published) { + throw new WebinyError("Cannot have both latest and published params."); + } + let sortKey: string; + if (latest) { + sortKey = createLatestSortKey(); + } else if (published && !version) { + /** + * Because of the specifics how DynamoDB works, we must not load the published record if version is sent. + */ + sortKey = createLatestPublishedSortKey(); + } else if (id || version) { + sortKey = createRevisionSortKey(version || id); + } else { + throw new WebinyError( + "Missing parameter to create a sort key.", + "MISSING_WHERE_PARAMETER", + { + where + } + ); + } + + const keys = { + PK: createFormPartitionKey({ + tenant, + locale, + id: formId || id + }), + SK: sortKey + }; + + try { + const result = await entity.get(keys); + if (!result || !result.Item) { + return null; + } + return cleanupItem(entity, result.Item); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not get form by keys.", + ex.code || "GET_FORM_ERROR", + { + keys + } + ); + } + }; + + const listForms = async ( + params: FormBuilderStorageOperationsListFormsParams + ): Promise => { + const { sort, limit, where, after } = params; + + const body = createElasticsearchBody({ + plugins, + sort, + limit: limit + 1, + where, + after: decodeCursor(after) + }); + + const esConfig = configurations.es({ + tenant: where.tenant + }); + + const query = { + ...esConfig, + body + }; + + let response; + try { + response = await elasticsearch.search(query); + } catch (ex) { + throw new WebinyError( + ex.message || "Could list forms.", + ex.code || "LIST_FORMS_ERROR", + { + where, + query + } + ); + } + + const { hits, total } = response.body.hits; + const items = hits.map(item => item._source); + + const hasMoreItems = items.length > limit; + if (hasMoreItems) { + /** + * Remove the last item from results, we don't want to include it. + */ + items.pop(); + } + /** + * Cursor is the `sort` value of the last item in the array. + * https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#search-after + */ + + const meta = { + hasMoreItems, + totalCount: total.value, + cursor: items.length > 0 ? encodeCursor(hits[items.length - 1].sort) : null + }; + + return { + items, + meta + }; + }; + + const listFormRevisions = async ( + params: FormBuilderStorageOperationsListFormRevisionsParams + ): Promise => { + const { where: initialWhere, sort } = params; + const { id, formId, tenant, locale } = initialWhere; + const queryAllParams: QueryAllParams = { + entity, + partitionKey: createFormPartitionKey({ + tenant, + locale, + id: id || formId + }), + options: { + beginsWith: "REV#" + } + }; + + let items: FbForm[] = []; + try { + items = await queryAll(queryAllParams); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not query forms by given params.", + ex.code || "QUERY_FORMS_ERROR", + { + partitionKey: queryAllParams.partitionKey, + options: queryAllParams.options + } + ); + } + const where = { + ...initialWhere, + id: undefined, + formId: undefined + }; + const filteredItems = filterItems({ + /** + * At the moment we need to send the plugins like this because plugins are extracted from the context.plugins. + * When we implement sending only plugins that we require, we will change this as well. + */ + context: { + plugins + } as any, + items, + where, + fields: formDynamoDbFields + }); + if (Array.isArray(sort) === false || sort.length === 0) { + return filteredItems; + } + return sortItems({ + items: filteredItems, + sort, + fields: formDynamoDbFields + }); + }; + + const deleteForm = async ( + params: FormBuilderStorageOperationsDeleteFormParams + ): Promise => { + const { form } = params; + let items: any[]; + /** + * This will find all form and submission records. + */ + const queryAllParams = { + entity, + partitionKey: createFormPartitionKey(form), + options: { + gte: " " + } + }; + try { + items = await queryAll(queryAllParams); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not query forms and submissions by given params.", + ex.code || "QUERY_FORM_AND_SUBMISSIONS_ERROR", + { + partitionKey: queryAllParams.partitionKey, + options: queryAllParams.options + } + ); + } + + const deleteItems = items.map(item => { + return entity.deleteBatch({ + PK: item.PK, + SK: item.SK + }); + }); + try { + await batchWriteAll({ + table, + items: deleteItems + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form and it's submissions.", + ex.code || "DELETE_FORM_AND_SUBMISSIONS_ERROR" + ); + } + + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + try { + await esEntity.delete(latestKeys); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete latest form record from Elasticsearch.", + ex.code || "DELETE_FORM_ERROR", + { + latestKeys + } + ); + } + return form; + }; + /** + * We need to: + * - delete current revision + * - get previously published revision and update the record if it exists or delete if it does not + * - update latest record if current one is the latest + */ + const deleteFormRevision = async ( + params: FormBuilderStorageOperationsDeleteFormRevisionParams + ): Promise => { + const { form, revisions, previous } = params; + + const revisionKeys = { + PK: createFormPartitionKey(form), + SK: createRevisionSortKey(form.id) + }; + + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + + const latestForm = revisions[0]; + const latestPublishedForm = revisions.find(rev => rev.published === true); + + const isLatest = latestForm ? latestForm.id === form.id : false; + const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; + + const items = [entity.deleteBatch(revisionKeys)]; + let esDataItem = undefined; + + if (isLatest || isLatestPublished) { + /** + * Sort out the latest published record. + */ + if (isLatestPublished) { + const previouslyPublishedForm = revisions + .filter(f => !!f.publishedOn && f.version !== form.version) + .sort((a, b) => { + return ( + new Date(b.publishedOn).getTime() - new Date(a.publishedOn).getTime() + ); + }) + .shift(); + if (previouslyPublishedForm) { + items.push( + entity.putBatch({ + ...previouslyPublishedForm, + PK: createFormPartitionKey(previouslyPublishedForm), + SK: createLatestPublishedSortKey(), + TYPE: createFormLatestPublishedType() + }) + ); + } else { + items.push( + entity.deleteBatch({ + PK: createFormPartitionKey(previouslyPublishedForm), + SK: createLatestPublishedSortKey() + }) + ); + } + } + /** + * Sort out the latest record. + */ + if (isLatest) { + items.push( + entity.putBatch({ + ...previous, + ...latestKeys, + TYPE: createFormLatestType() + }) + ); + + const { index } = configurations.es({ + tenant: previous.tenant + }); + + esDataItem = { + index, + ...latestKeys, + data: getESDataForLatestRevision(previous) + }; + } + } + /** + * Now save the batch data. + */ + try { + await batchWriteAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form revision from regular table.", + ex.code || "DELETE_FORM_REVISION_ERROR", + { + form, + latestForm, + revisionKeys, + latestKeys + } + ); + } + /** + * And then the Elasticsearch data, if any. + */ + if (!esDataItem) { + return form; + } + try { + await esEntity.put(esDataItem); + return form; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form from to the Elasticsearch table.", + ex.code || "DELETE_FORM_REVISION_ERROR", + { + form, + latestForm, + revisionKeys, + latestKeys + } + ); + } + }; + + /** + * We need to save form in: + * - regular form record + * - latest published form record + * - latest form record - if form is latest one + * - elasticsearch latest form record + */ + const publishForm = async ( + params: FormBuilderStorageOperationsPublishFormParams + ): Promise => { + const { form, original } = params; + + const revisionKeys = { + PK: createFormPartitionKey(form), + SK: createRevisionSortKey(form.version) + }; + + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + + const latestPublishedKeys = { + PK: createFormPartitionKey(form), + SK: createLatestPublishedSortKey() + }; + + const { locale, tenant, formId } = form; + + const latestForm = await getForm({ + where: { + formId, + tenant, + locale, + latest: true + } + }); + + const isLatestForm = latestForm ? latestForm.id === form.id : false; + /** + * Update revision and latest published records + */ + const items = [ + entity.putBatch({ + ...form, + ...revisionKeys, + TYPE: createFormType() + }), + entity.putBatch({ + ...form, + ...latestPublishedKeys, + TYPE: createFormLatestPublishedType() + }) + ]; + /** + * Update the latest form as well + */ + if (isLatestForm) { + items.push( + entity.putBatch({ + ...form, + ...latestKeys, + TYPE: createFormLatestType() + }) + ); + } + + try { + await batchWriteAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not publish form.", + ex.code || "PUBLISH_FORM_ERROR", + { + form, + original, + latestForm, + revisionKeys, + latestKeys, + latestPublishedKeys + } + ); + } + if (!isLatestForm) { + return form; + } + const { index } = configurations.es({ + tenant: form.tenant + }); + const esData = getESDataForLatestRevision(form); + try { + await esEntity.put({ + ...latestKeys, + index, + TYPE: createFormLatestType(), + data: esData + }); + return form; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not publish form to the Elasticsearch.", + ex.code || "PUBLISH_FORM_ERROR", + { + form, + original, + latestForm, + revisionKeys, + latestKeys, + latestPublishedKeys + } + ); + } + }; + + /** + * We need to: + * - update form revision record + * - if latest published (LP) is current form, find the previously published record and update LP if there is some previously published, delete otherwise + * - if is latest update the Elasticsearch record + */ + const unpublishForm = async ( + params: FormBuilderStorageOperationsUnpublishFormParams + ): Promise => { + const { form, original } = params; + + const revisionKeys = { + PK: createFormPartitionKey(form), + SK: createRevisionSortKey(form.version) + }; + + const latestKeys = { + PK: createFormPartitionKey(form), + SK: createLatestSortKey() + }; + + const latestPublishedKeys = { + PK: createFormPartitionKey(form), + SK: createLatestPublishedSortKey() + }; + + const { formId, tenant, locale } = form; + + const latestForm = await getForm({ + where: { + formId, + tenant, + locale, + latest: true + } + }); + + const latestPublishedForm = await getForm({ + where: { + formId, + tenant, + locale, + published: true + } + }); + + const isLatest = latestForm ? latestForm.id === form.id : false; + const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; + + const items = [ + entity.putBatch({ + ...form, + ...revisionKeys, + TYPE: createFormType() + }) + ]; + let esData: any = undefined; + if (isLatest) { + esData = getESDataForLatestRevision(form); + } + /** + * In case previously published revision exists, replace current one with that one. + * And if it does not, delete the record. + */ + if (isLatestPublished) { + const revisions = await listFormRevisions({ + where: { + formId, + tenant, + locale, + version_not: form.version, + publishedOn_not: null + }, + sort: ["savedOn_DESC"] + }); + + const previouslyPublishedRevision = revisions.shift(); + if (previouslyPublishedRevision) { + items.push( + entity.putBatch({ + ...previouslyPublishedRevision, + ...latestPublishedKeys, + TYPE: createFormLatestPublishedType() + }) + ); + } else { + items.push(entity.deleteBatch(latestPublishedKeys)); + } + } + + try { + await batchWriteAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not unpublish form.", + ex.code || "UNPUBLISH_FORM_ERROR", + { + form, + original, + latestForm, + revisionKeys, + latestKeys, + latestPublishedKeys + } + ); + } + /** + * No need to go further in case of non-existing Elasticsearch data. + */ + if (!esData) { + return form; + } + const { index } = configurations.es({ + tenant: form.tenant + }); + try { + await esEntity.put({ + ...latestKeys, + index, + TYPE: createFormLatestType(), + data: esData + }); + return form; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not unpublish form from the Elasticsearch.", + ex.code || "UNPUBLISH_FORM_ERROR", + { + form, + original, + latestForm, + revisionKeys, + latestKeys, + latestPublishedKeys + } + ); + } + }; + + return { + createForm, + createFormFrom, + updateForm, + listForms, + listFormRevisions, + getForm, + deleteForm, + deleteFormRevision, + publishForm, + unpublishForm, + createFormPartitionKey + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/settings/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/settings/index.ts new file mode 100644 index 00000000000..22cbcb4418e --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/settings/index.ts @@ -0,0 +1,141 @@ +import { + FormBuilderStorageOperationsCreateSettingsParams, + FormBuilderStorageOperationsDeleteSettingsParams, + FormBuilderStorageOperationsGetSettingsParams, + FormBuilderStorageOperationsUpdateSettingsParams, + Settings +} from "@webiny/api-form-builder/types"; +import { Entity, Table } from "dynamodb-toolbox"; +import { + FormBuilderSettingsStorageOperations, + FormBuilderSettingsStorageOperationsCreatePartitionKeyParams +} from "~/types"; +import WebinyError from "@webiny/error"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; + +export interface Params { + entity: Entity; + table: Table; +} + +export const createSettingsStorageOperations = ( + params: Params +): FormBuilderSettingsStorageOperations => { + const { entity } = params; + + const createSettingsPartitionKey = ({ + tenant, + locale + }: FormBuilderSettingsStorageOperationsCreatePartitionKeyParams): string => { + return `T#${tenant}#L#${locale}#FB#SETTINGS`; + }; + + const createSettingsSortKey = (): string => { + return "default"; + }; + + const createKeys = (params: FormBuilderSettingsStorageOperationsCreatePartitionKeyParams) => { + return { + PK: createSettingsPartitionKey(params), + SK: createSettingsSortKey() + }; + }; + + const createSettings = async ( + params: FormBuilderStorageOperationsCreateSettingsParams + ): Promise => { + const { settings } = params; + const keys = createKeys(settings); + + try { + await entity.put({ + ...settings, + ...keys + }); + return settings; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create the settings record by given keys.", + ex.code || "CREATE_SETTINGS_ERROR", + { + keys, + settings + } + ); + } + }; + + const getSettings = async ( + params: FormBuilderStorageOperationsGetSettingsParams + ): Promise => { + const keys = createKeys(params); + + try { + const result = await entity.get(keys); + if (!result || !result.Item) { + return null; + } + return cleanupItem(entity, result.Item); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not get the settings record by given keys.", + ex.code || "LOAD_SETTINGS_ERROR", + { + keys + } + ); + } + }; + + const updateSettings = async ( + params: FormBuilderStorageOperationsUpdateSettingsParams + ): Promise => { + const { settings, original } = params; + const keys = createKeys(settings); + + try { + await entity.put({ + ...settings, + ...keys + }); + return settings; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update the settings record by given keys.", + ex.code || "UPDATE_SETTINGS_ERROR", + { + keys, + original, + settings + } + ); + } + }; + + const deleteSettings = async ( + params: FormBuilderStorageOperationsDeleteSettingsParams + ): Promise => { + const { settings } = params; + const keys = createKeys(settings); + try { + await entity.delete(); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete the settings record by given keys.", + ex.code || "DELETE_SETTINGS_ERROR", + { + keys + } + ); + } + }; + + return { + createSettings, + getSettings, + updateSettings, + deleteSettings, + createSettingsPartitionKey, + createSettingsSortKey + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchBody.ts b/packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchBody.ts new file mode 100644 index 00000000000..4bdb92870fb --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchBody.ts @@ -0,0 +1,191 @@ +import { SearchBody as esSearchBody } from "elastic-ts"; +import { decodeCursor } from "@webiny/api-elasticsearch/cursors"; +import { ElasticsearchBoolQueryConfig } from "@webiny/api-elasticsearch/types"; +import { createSort } from "@webiny/api-elasticsearch/sort"; +import { createLimit } from "@webiny/api-elasticsearch/limit"; +import { ElasticsearchQueryBuilderOperatorPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchQueryBuilderOperatorPlugin"; +import { SubmissionElasticsearchFieldPlugin } from "~/plugins/SubmissionElasticsearchFieldPlugin"; +import { SubmissionElasticsearchSortModifierPlugin } from "~/plugins/SubmissionElasticsearchSortModifierPlugin"; +import { SubmissionElasticsearchBodyModifierPlugin } from "~/plugins/SubmissionElasticsearchBodyModifierPlugin"; +import { FormBuilderStorageOperationsListSubmissionsParams } from "@webiny/api-form-builder/types"; +import { SubmissionElasticsearchQueryModifierPlugin } from "~/plugins/SubmissionElasticsearchQueryModifierPlugin"; +import { PluginsContainer } from "@webiny/plugins"; +import { applyWhere } from "@webiny/api-elasticsearch/where"; + +const createInitialQueryValue = (): ElasticsearchBoolQueryConfig => { + return { + must: [ + /** + * We add the __type filtering in the initial query because it must be applied. + */ + { + term: { + "__type.keyword": "fb.submission" + } + } + ], + must_not: [], + should: [], + filter: [] + }; +}; + +export const createSubmissionElasticType = (): string => { + return "fb.submission"; +}; + +interface CreateElasticsearchQueryParams extends CreateElasticsearchBodyParams { + fieldPlugins: Record; +} + +const createElasticsearchQuery = (params: CreateElasticsearchQueryParams) => { + const { plugins, where: initialWhere, fieldPlugins } = params; + const query = createInitialQueryValue(); + /** + * Be aware that, if having more registered operator plugins of same type, the last one will be used. + */ + const operatorPlugins: Record = plugins + .byType( + ElasticsearchQueryBuilderOperatorPlugin.type + ) + .reduce((acc, plugin) => { + acc[plugin.getOperator()] = plugin; + return acc; + }, {}); + + const where: FormBuilderStorageOperationsListSubmissionsParams["where"] = { + ...initialWhere + }; + /** + * !!! IMPORTANT !!! There are few specific cases where we hardcode the query conditions. + * + * When ES index is shared between tenants, we need to filter records by tenant ID. + * No need for the tenant filtering otherwise as each index is for single tenant. + */ + const sharedIndex = process.env.ELASTICSEARCH_SHARED_INDEXES === "true"; + if (sharedIndex) { + query.must.push({ + term: { + "tenant.keyword": where.tenant + } + }); + } + /** + * Remove tenant so it is not applied again later. + * Possibly tenant is not defined, but just in case, remove it. + */ + delete where.tenant; + /** + * Add the locale to filtering. + */ + query.must.push({ + term: { + "locale.keyword": where.locale + } + }); + delete where.locale; + /** + * And add the parent (form) to the filtering, if it exists. + */ + query.must.push({ + term: { + "form.parent.keyword": where.parent + } + }); + delete where.parent; + /** + * We apply other conditions as they are passed via the where value. + */ + applyWhere({ + query, + where, + fields: fieldPlugins, + operators: operatorPlugins + }); + + return query; +}; + +interface CreateElasticsearchBodyParams { + plugins: PluginsContainer; + where: FormBuilderStorageOperationsListSubmissionsParams["where"]; + limit: number; + after?: string; + sort: string[]; +} + +export const createElasticsearchBody = (params: CreateElasticsearchBodyParams): esSearchBody => { + const { plugins, where, limit: initialLimit, sort: initialSort, after } = params; + + const fieldPlugins: Record = plugins + .byType(SubmissionElasticsearchFieldPlugin.type) + .reduce((acc, plugin) => { + acc[plugin.field] = plugin; + return acc; + }, {}); + + const limit = createLimit(initialLimit, 100); + + const query = createElasticsearchQuery({ + ...params, + fieldPlugins + }); + + const sort = createSort({ + sort: initialSort, + fieldPlugins + }); + + const queryModifiers = plugins.byType( + SubmissionElasticsearchQueryModifierPlugin.type + ); + + for (const plugin of queryModifiers) { + plugin.modifyQuery({ + query, + where + }); + } + + const sortModifiers = plugins.byType( + SubmissionElasticsearchSortModifierPlugin.type + ); + + for (const plugin of sortModifiers) { + plugin.modifySort({ + sort + }); + } + + const body = { + query: { + constant_score: { + filter: { + bool: { + ...query + } + } + } + }, + size: limit + 1, + /** + * Casting as any is required due to search_after is accepting an array of values. + * Which is correct in some cases. In our case, it is not. + * https://www.elastic.co/guide/en/elasticsearch/reference/7.13/paginate-search-results.html + */ + search_after: decodeCursor(after) as any, + sort + }; + + const bodyModifiers = plugins.byType( + SubmissionElasticsearchBodyModifierPlugin.type + ); + + for (const plugin of bodyModifiers) { + plugin.modifyBody({ + body + }); + } + + return body; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchFields.ts b/packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchFields.ts new file mode 100644 index 00000000000..e8a140c55a4 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/submission/elasticsearchFields.ts @@ -0,0 +1,26 @@ +import { SubmissionElasticsearchFieldPlugin } from "~/plugins/SubmissionElasticsearchFieldPlugin"; + +export default () => [ + new SubmissionElasticsearchFieldPlugin({ + field: "parent", + path: "form.parent" + }), + new SubmissionElasticsearchFieldPlugin({ + field: "ownedBy", + path: "ownedBy.id" + }), + new SubmissionElasticsearchFieldPlugin({ + field: "createdOn", + unmappedType: "date" + }), + new SubmissionElasticsearchFieldPlugin({ + field: "savedOn", + unmappedType: "date" + }), + /** + * Always add the ALL fields plugin because of the keyword/path build. + */ + new SubmissionElasticsearchFieldPlugin({ + field: SubmissionElasticsearchFieldPlugin.ALL + }) +]; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts new file mode 100644 index 00000000000..68fe3a10bd9 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts @@ -0,0 +1,373 @@ +import { + FbSubmission, + FormBuilderStorageOperationsCreateSubmissionParams, + FormBuilderStorageOperationsDeleteSubmissionParams, + FormBuilderStorageOperationsGetSubmissionParams, + FormBuilderStorageOperationsListSubmissionsParams, + FormBuilderStorageOperationsListSubmissionsResponse, + FormBuilderStorageOperationsUpdateSubmissionParams +} from "@webiny/api-form-builder/types"; +import { Entity, Table } from "dynamodb-toolbox"; +import { Client } from "@elastic/elasticsearch"; +import WebinyError from "@webiny/error"; +import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { sortItems } from "@webiny/db-dynamodb/utils/sort"; +import { createLimit } from "@webiny/api-elasticsearch/limit"; +import { + createElasticsearchBody, + createSubmissionElasticType +} from "~/operations/submission/elasticsearchBody"; +import { PluginsContainer } from "@webiny/plugins"; +import { + FormBuilderSubmissionStorageOperations, + FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams +} from "~/types"; +import configurations from "~/configurations"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import { parseIdentifier } from "@webiny/utils"; +import { decodeCursor, encodeCursor } from "@webiny/api-elasticsearch/cursors"; + +export interface Params { + entity: Entity; + esEntity: Entity; + table: Table; + elasticsearch: Client; + plugins: PluginsContainer; +} + +export const createSubmissionStorageOperations = ( + params: Params +): FormBuilderSubmissionStorageOperations => { + const { entity, esEntity, table, elasticsearch, plugins } = params; + + /** + * This is a form partition key. + * TODO: figure out how to use the one from the ~/operations/forms/index.ts file. + */ + const createSubmissionPartitionKey = ( + params: FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams + ) => { + const { tenant, locale, formId } = params; + + const { id } = parseIdentifier(formId); + + return `T#${tenant}#L#${locale}#FB#F#${id}`; + }; + const createSubmissionSortKey = (id: string) => { + return `FS#${id}`; + }; + + const createSubmissionType = () => { + return "fb.formSubmission"; + }; + + const createSubmission = async ( + params: FormBuilderStorageOperationsCreateSubmissionParams + ): Promise => { + const { submission, form } = params; + const keys = { + PK: createSubmissionPartitionKey(form), + SK: createSubmissionSortKey(submission.id) + }; + + try { + await entity.put({ + ...submission, + ...keys, + TYPE: createSubmissionType() + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create form submission in the DynamoDB.", + ex.code || "UPDATE_FORM_SUBMISSION_ERROR", + { + submission, + form, + keys + } + ); + } + + try { + const { index } = configurations.es({ + tenant: form.tenant + }); + await esEntity.put({ + index, + data: { + ...submission, + __type: createSubmissionElasticType() + }, + TYPE: createSubmissionType(), + ...keys + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create form submission in the Elasticsearch.", + ex.code || "UPDATE_FORM_SUBMISSION_ERROR", + { + submission, + form, + keys + } + ); + } + + return submission; + }; + /** + * We do not save the data in the Elasticsearch because there is no need for that. + */ + const updateSubmission = async ( + params: FormBuilderStorageOperationsUpdateSubmissionParams + ): Promise => { + const { submission, form, original } = params; + const keys = { + PK: createSubmissionPartitionKey(form), + SK: createSubmissionSortKey(submission.id) + }; + + try { + await entity.put({ + ...submission, + ...keys, + TYPE: createSubmissionType() + }); + return submission; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form submission in the DynamoDB.", + ex.code || "UPDATE_FORM_SUBMISSION_ERROR", + { + submission, + original, + form, + keys + } + ); + } + }; + + const deleteSubmission = async ( + params: FormBuilderStorageOperationsDeleteSubmissionParams + ): Promise => { + const { submission, form } = params; + + const keys = { + PK: createSubmissionPartitionKey(form), + SK: createSubmissionSortKey(submission.id) + }; + + try { + await entity.delete(keys); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form submission from DynamoDB.", + ex.code || "DELETE_FORM_SUBMISSION_ERROR", + { + submission, + form, + keys + } + ); + } + + try { + await esEntity.delete(keys); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form submission from Elasticsearch.", + ex.code || "DELETE_FORM_SUBMISSION_ERROR", + { + submission, + form, + keys + } + ); + } + + return submission; + }; + + /** + * + * We are using this method because it is faster to fetch the exact data from the DynamoDB than Elasticsearch. + * + * @internal + */ + const listSubmissionsByIds = async ( + params: FormBuilderStorageOperationsListSubmissionsParams + ): Promise => { + const { where, sort } = params; + const items = where.id_in.map(id => { + return entity.getBatch({ + PK: createSubmissionPartitionKey({ + ...where, + formId: where.parent + }), + SK: createSubmissionSortKey(id) + }); + }); + + let results: FbSubmission[] = []; + + try { + results = await batchReadAll({ + table, + items + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not batch read form submissions.", + ex.code || "BATCH_READ_SUBMISSIONS_ERROR", + { + where, + sort + } + ); + } + /** + * We need to remove empty results because it is a possibility that batch read returned null for non-existing record. + */ + const submissions = results.filter(Boolean).map(submission => { + return cleanupItem(entity, submission); + }); + if (!sort) { + return submissions; + } + return sortItems({ + items: submissions, + sort, + fields: [] + }); + }; + + const listSubmissions = async ( + params: FormBuilderStorageOperationsListSubmissionsParams + ): Promise => { + const { where, sort, limit: initialLimit, after } = params; + + if (where.id_in) { + if (!where.parent) { + throw new WebinyError( + `Cannot search for form submissions by IDs without the "parent".`, + "MALFORMED_WHERE_CONDITION", + { + where + } + ); + } + const items = await listSubmissionsByIds(params); + + return { + items, + meta: { + hasMoreItems: false, + cursor: null, + totalCount: items.length + } + }; + } + + const limit = createLimit(initialLimit); + + const body = createElasticsearchBody({ + plugins, + sort, + limit: limit + 1, + where, + after: decodeCursor(after) + }); + + const esConfig = configurations.es({ + tenant: where.tenant + }); + + const query = { + ...esConfig, + body + }; + + let response; + try { + response = await elasticsearch.search(query); + } catch (ex) { + throw new WebinyError( + ex.message || "Could list form submissions.", + ex.code || "LIST_SUBMISSIONS_ERROR", + { + where, + query + } + ); + } + + const { hits, total } = response.body.hits; + const items = hits.map(item => item._source); + + const hasMoreItems = items.length > limit; + if (hasMoreItems) { + /** + * Remove the last item from results, we don't want to include it. + */ + items.pop(); + } + /** + * Cursor is the `sort` value of the last item in the array. + * https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#search-after + */ + const meta = { + hasMoreItems, + totalCount: total.value, + cursor: items.length > 0 ? encodeCursor(hits[items.length - 1].sort) : null + }; + + return { + items, + meta + }; + }; + + const getSubmission = async ( + params: FormBuilderStorageOperationsGetSubmissionParams + ): Promise => { + const { where } = params; + + const keys = { + PK: createSubmissionPartitionKey({ + ...where, + formId: where.formId as string + }), + SK: createSubmissionSortKey(where.id) + }; + + try { + const result = await entity.get(keys); + + if (!result || !result.Item) { + return null; + } + + return cleanupItem(entity, result.Item); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not oad submission.", + ex.code || "GET_SUBMISSION_ERROR", + { + where, + keys + } + ); + } + }; + + return { + createSubmission, + deleteSubmission, + updateSubmission, + listSubmissions, + getSubmission, + createSubmissionPartitionKey, + createSubmissionSortKey + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/system/createElasticsearchIndex.ts b/packages/api-form-builder-so-ddb-es/src/operations/system/createElasticsearchIndex.ts new file mode 100644 index 00000000000..d1ecc1a7c0a --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/system/createElasticsearchIndex.ts @@ -0,0 +1,54 @@ +import { Client } from "@elastic/elasticsearch"; +import configurations from "~/configurations"; +import { Tenant } from "@webiny/api-tenancy/types"; + +export interface Params { + elasticsearch: Client; + tenant: Tenant; +} + +export const createElasticsearchIndex = async (params: Params) => { + const { tenant, elasticsearch } = params; + + const esIndex = configurations.es({ + tenant: tenant.id + }); + + const { body: exists } = await elasticsearch.indices.exists(esIndex); + if (exists) { + return; + } + await elasticsearch.indices.create({ + ...esIndex, + body: { + /** + * need this part for sorting to work on text fields + */ + settings: { + analysis: { + analyzer: { + lowercase_analyzer: { + type: "custom", + filter: ["lowercase", "trim"], + tokenizer: "keyword" + } + } + } + }, + mappings: { + properties: { + property: { + type: "text", + fields: { + keyword: { + type: "keyword", + ignore_above: 256 + } + }, + analyzer: "lowercase_analyzer" + } + } + } + } + }); +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/system/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/system/index.ts new file mode 100644 index 00000000000..c0f7a8baff8 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/system/index.ts @@ -0,0 +1,115 @@ +import { + FormBuilderStorageOperationsCreateSystemParams, + FormBuilderStorageOperationsGetSystemParams, + FormBuilderStorageOperationsUpdateSystemParams, + System +} from "@webiny/api-form-builder/types"; +import { Entity, Table } from "dynamodb-toolbox"; +import { FormBuilderSystemCreateKeysParams, FormBuilderSystemStorageOperations } from "~/types"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import WebinyError from "@webiny/error"; + +export interface Params { + entity: Entity; + table: Table; +} + +export const createSystemStorageOperations = ( + params: Params +): FormBuilderSystemStorageOperations => { + const { entity } = params; + + const createSystemPartitionKey = ({ tenant }: FormBuilderSystemCreateKeysParams): string => { + return `T#${tenant}#SYSTEM`; + }; + + const createSystemSortKey = (): string => { + return "FB"; + }; + + const createKeys = (params: FormBuilderSystemCreateKeysParams) => { + return { + PK: createSystemPartitionKey(params), + SK: createSystemSortKey() + }; + }; + + const createSystem = async ( + params: FormBuilderStorageOperationsCreateSystemParams + ): Promise => { + const { system } = params; + const keys = createKeys(system); + + try { + await entity.put({ + ...system, + ...keys + }); + return system; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create the system record by given keys.", + ex.code || "CREATE_SYSTEM_ERROR", + { + keys, + system + } + ); + } + }; + + const getSystem = async ( + params: FormBuilderStorageOperationsGetSystemParams + ): Promise => { + const keys = createKeys(params); + + try { + const result = await entity.get(keys); + if (!result || !result.Item) { + return null; + } + return cleanupItem(entity, result.Item); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not get the system record by given keys.", + ex.code || "LOAD_SYSTEM_ERROR", + { + keys + } + ); + } + }; + + const updateSystem = async ( + params: FormBuilderStorageOperationsUpdateSystemParams + ): Promise => { + const { system, original } = params; + const keys = createKeys(system); + + try { + await entity.put({ + ...system, + ...keys + }); + return system; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update the system record by given keys.", + ex.code || "UPDATE_SYSTEM_ERROR", + { + keys, + original, + system + } + ); + } + }; + + return { + createSystem, + getSystem, + updateSystem, + createSystemPartitionKey, + createSystemSortKey + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/FormDynamoDbFieldPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/FormDynamoDbFieldPlugin.ts new file mode 100644 index 00000000000..309256121ab --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/FormDynamoDbFieldPlugin.ts @@ -0,0 +1,5 @@ +import { FieldPlugin } from "@webiny/db-dynamodb/plugins/definitions/FieldPlugin"; + +export class FormDynamoDbFieldPlugin extends FieldPlugin { + public static readonly type: string = "formBuilder.dynamodb.field.form"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchBodyModifierPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchBodyModifierPlugin.ts new file mode 100644 index 00000000000..1f9547a03cf --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchBodyModifierPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchBodyModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchBodyModifierPlugin"; + +export class FormElasticsearchBodyModifierPlugin extends ElasticsearchBodyModifierPlugin { + public static readonly type: string = "formBuilder.elasticsearch.modifier.body.form"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchFieldPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchFieldPlugin.ts new file mode 100644 index 00000000000..69cf035bfe1 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchFieldPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchFieldPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchFieldPlugin"; + +export class FormElasticsearchFieldPlugin extends ElasticsearchFieldPlugin { + public static readonly type: string = "formBuilder.elasticsearch.fieldDefinition.form"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchQueryModifierPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchQueryModifierPlugin.ts new file mode 100644 index 00000000000..8c1272c9b27 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchQueryModifierPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchQueryModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchQueryModifierPlugin"; + +export class FormElasticsearchQueryModifierPlugin extends ElasticsearchQueryModifierPlugin { + public static readonly type: string = "formBuilder.elasticsearch.modifier.query.form"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchSortModifierPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchSortModifierPlugin.ts new file mode 100644 index 00000000000..e8ebe857ac8 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/FormElasticsearchSortModifierPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchSortModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchSortModifierPlugin"; + +export class FormElasticsearchSortModifierPlugin extends ElasticsearchSortModifierPlugin { + public static readonly type: string = "formBuilder.elasticsearch.modifier.sort.form"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchBodyModifierPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchBodyModifierPlugin.ts new file mode 100644 index 00000000000..efa346de1ff --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchBodyModifierPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchBodyModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchBodyModifierPlugin"; + +export class SubmissionElasticsearchBodyModifierPlugin extends ElasticsearchBodyModifierPlugin { + public static readonly type: string = "formBuilder.elasticsearch.modifier.body.submission"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchFieldPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchFieldPlugin.ts new file mode 100644 index 00000000000..0e1cb245c50 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchFieldPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchFieldPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchFieldPlugin"; + +export class SubmissionElasticsearchFieldPlugin extends ElasticsearchFieldPlugin { + public static readonly type: string = "formBuilder.elasticsearch.fieldDefinition.submission"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchQueryModifierPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchQueryModifierPlugin.ts new file mode 100644 index 00000000000..3abf12d6176 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchQueryModifierPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchQueryModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchQueryModifierPlugin"; + +export class SubmissionElasticsearchQueryModifierPlugin extends ElasticsearchQueryModifierPlugin { + public static readonly type: string = "formBuilder.elasticsearch.modifier.query.submission"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchSortModifierPlugin.ts b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchSortModifierPlugin.ts new file mode 100644 index 00000000000..7bea697fe5c --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/plugins/SubmissionElasticsearchSortModifierPlugin.ts @@ -0,0 +1,5 @@ +import { ElasticsearchSortModifierPlugin } from "@webiny/api-elasticsearch/plugins/definition/ElasticsearchSortModifierPlugin"; + +export class SubmissionElasticsearchSortModifierPlugin extends ElasticsearchSortModifierPlugin { + public static readonly type: string = "formBuilder.elasticsearch.modifier.sort.submission"; +} diff --git a/packages/api-form-builder-so-ddb-es/src/types.ts b/packages/api-form-builder-so-ddb-es/src/types.ts new file mode 100644 index 00000000000..2f3f44a5ad3 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/types.ts @@ -0,0 +1,101 @@ +import { + FormBuilderStorageOperations as BaseFormBuilderStorageOperations, + FormBuilderSystemStorageOperations as BaseFormBuilderSystemStorageOperations, + FormBuilderSubmissionStorageOperations as BaseFormBuilderSubmissionStorageOperations, + FormBuilderSettingsStorageOperations as BaseFormBuilderSettingsStorageOperations, + FormBuilderFormStorageOperations as BaseFormBuilderFormStorageOperations +} from "@webiny/api-form-builder/types"; +import { DocumentClient } from "aws-sdk/clients/dynamodb"; +import { Table, Entity } from "dynamodb-toolbox"; +import { DynamoDBTypes } from "dynamodb-toolbox/dist/classes/Table"; +import { + EntityAttributeConfig, + EntityCompositeAttributes +} from "dynamodb-toolbox/dist/classes/Entity"; +import { Client } from "@elastic/elasticsearch"; +import { Plugin } from "@webiny/plugins"; + +export type AttributeDefinition = DynamoDBTypes | EntityAttributeConfig | EntityCompositeAttributes; + +export type Attributes = Record; + +export enum ENTITIES { + FORM = "FormBuilderForm", + ES_FORM = "FormBuilderFormEs", + SUBMISSION = "FormBuilderSubmission", + ES_SUBMISSION = "FormBuilderSubmissionEs", + SYSTEM = "FormBuilderSystem", + SETTINGS = "FormBuilderSettings" +} + +export interface FormBuilderStorageOperationsFactoryParams { + documentClient: DocumentClient; + elasticsearch: Client; + table?: string; + esTable?: string; + attributes?: Record; + plugins?: Plugin; +} + +export interface FormBuilderSystemCreateKeysParams { + tenant: string; +} + +export interface FormBuilderSystemStorageOperations extends BaseFormBuilderSystemStorageOperations { + createSystemPartitionKey: (params: FormBuilderSystemCreateKeysParams) => string; + createSystemSortKey: () => string; +} + +export interface FormBuilderFormCreateKeyParams { + id: string; + tenant: string; + locale: string; +} + +export interface FormBuilderFormStorageOperations extends BaseFormBuilderFormStorageOperations { + createFormPartitionKey: (params: FormBuilderFormCreateKeyParams) => string; +} + +export interface FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams { + tenant: string; + locale: string; + formId: string; +} + +export interface FormBuilderSubmissionStorageOperations + extends BaseFormBuilderSubmissionStorageOperations { + createSubmissionPartitionKey: ( + params: FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams + ) => string; + createSubmissionSortKey: (id: string) => string; +} + +export interface FormBuilderSettingsStorageOperationsCreatePartitionKeyParams { + tenant: string; + locale: string; +} + +export interface FormBuilderSettingsStorageOperations + extends BaseFormBuilderSettingsStorageOperations { + createSettingsPartitionKey: ( + params: FormBuilderSettingsStorageOperationsCreatePartitionKeyParams + ) => string; + createSettingsSortKey: () => string; +} + +export type Entities = "form" | "esForm" | "submission" | "esSubmission" | "system" | "settings"; + +export interface FormBuilderStorageOperations + extends BaseFormBuilderStorageOperations, + FormBuilderSettingsStorageOperations, + FormBuilderSubmissionStorageOperations, + FormBuilderFormStorageOperations, + FormBuilderSystemStorageOperations { + getTable(): Table; + getEsTable(): Table; + getEntities(): Record>; +} + +export interface FormBuilderStorageOperationsFactory { + (params: FormBuilderStorageOperationsFactoryParams): FormBuilderStorageOperations; +} diff --git a/packages/api-form-builder-so-ddb-es/src/upgrades/5.16.0/index.ts b/packages/api-form-builder-so-ddb-es/src/upgrades/5.16.0/index.ts new file mode 100644 index 00000000000..750e4f6525c --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/upgrades/5.16.0/index.ts @@ -0,0 +1,121 @@ +import { UpgradePlugin } from "@webiny/api-upgrade/types"; +import { FormBuilderContext } from "@webiny/api-form-builder/types"; +import { FormBuilderStorageOperations } from "~/types"; +import { queryAll } from "@webiny/db-dynamodb/utils/query"; +import { I18NLocale } from "@webiny/api-i18n/types"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { parseIdentifier } from "@webiny/utils"; +import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; +import WebinyError from "@webiny/error"; + +interface Params { + storageOperations: FormBuilderStorageOperations; + tenant: Tenant; + locale: I18NLocale; + webinyVersion: string; +} + +const upgradeForms = async (params: Params): Promise => { + const { storageOperations, tenant, locale, webinyVersion } = params; + /** + * We need all of the forms from the database. + * We are getting them from the Elasticsearch because there is no general PK for the forms. + */ + let forms: any[] = []; + try { + const { items } = await storageOperations.listForms({ + where: { + latest: true, + tenant: tenant.id, + locale: locale.code + }, + after: null, + limit: 10000, + sort: ["createdOn_DESC"] + }); + forms = items; + } catch (ex) { + console.log(ex.message); + return; + } + + if (forms.length === 0) { + return; + } + + const entity = storageOperations.getEntities().form; + const items: any[] = []; + /** + * ## Regular DynamoDB table. + * We need to get all the records from all of the forms. + * Unfortunately, we need to query in a loop to be able to get those forms. + */ + for (const form of forms) { + const { id: formId } = parseIdentifier(form.id); + + const formRecords: any[] = await queryAll({ + entity, + partitionKey: storageOperations.createFormPartitionKey({ + id: form.id, + tenant: tenant.id, + locale: locale.code + }) + }); + + for (const record of formRecords) { + /** + * Checks for "just in case". + */ + if (!record || !record.PK || !record.SK) { + continue; + } + items.push( + entity.putBatch({ + ...record, + formId, + webinyVersion + }) + ); + } + } + /** + * And finally write all the records to the database again. + */ + try { + await batchWriteAll({ + table: entity.table, + items + }); + } catch (ex) { + throw new WebinyError("Could not update all form records.", "UPGRADE_FORM_RECORDS_ERROR", { + error: ex + }); + } + /** + * ## Elasticsearch DynamoDB table. + */ +}; +/** + * This upgrade adds: + * - formId (first part of the id) and webinyVersion to the form records + */ +export default (): UpgradePlugin => { + return { + type: "api-upgrade", + app: "form-builder", + version: "5.16.0", + apply: async context => { + const tenant = context.tenancy.getCurrentTenant(); + const locale = context.i18nContent.getLocale(); + const storageOperations = context.formBuilder + .storageOperations as FormBuilderStorageOperations; + + await upgradeForms({ + storageOperations, + tenant, + locale, + webinyVersion: context.WEBINY_VERSION + }); + } + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/tsconfig.build.json b/packages/api-form-builder-so-ddb-es/tsconfig.build.json new file mode 100644 index 00000000000..3c4870d0e15 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/tsconfig.build.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["./src"], + "exclude": [ + "node_modules", + "../plugins", + "../api-form-builder", + "../error", + "../utils", + "../api-upgrade", + "../api-i18n", + "../handler", + "../handler-aws", + "../handler-db", + "../api-elasticsearch", + "../api-dynamodb-to-elasticsearch", + "../db-dynamodb", + "../api-tenancy" + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"] + }, + "baseUrl": "." + }, + "references": [] +} diff --git a/packages/api-form-builder-so-ddb-es/tsconfig.json b/packages/api-form-builder-so-ddb-es/tsconfig.json new file mode 100644 index 00000000000..f143666c60f --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/tsconfig.json @@ -0,0 +1,76 @@ +{ + "extends": "../../tsconfig", + "references": [ + { + "path": "../api-form-builder" + }, + { + "path": "../api-elasticsearch" + }, + { + "path": "../api-tenancy" + }, + { + "path": "../api-i18n" + }, + { + "path": "../api-upgrade" + }, + { + "path": "../db-dynamodb" + }, + { + "path": "../error" + }, + { + "path": "../utils" + }, + { + "path": "../handler" + }, + { + "path": "../handler-aws" + }, + { + "path": "../handler-db" + }, + { + "path": "../plugins" + }, + { + "path": "../api-dynamodb-to-elasticsearch" + } + ], + "compilerOptions": { + "paths": { + "~/*": ["./src/*"], + "@webiny/api-form-builder/*": ["../api-form-builder/src/*"], + "@webiny/api-form-builder": ["../api-form-builder/src"], + "@webiny/api-elasticsearch/*": ["../api-elasticsearch/src/*"], + "@webiny/api-elasticsearch": ["../api-elasticsearch/src"], + "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], + "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/api-i18n/*": ["../api-i18n/src/*"], + "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-upgrade/*": ["../api-upgrade/src/*"], + "@webiny/api-upgrade": ["../api-upgrade/src"], + "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], + "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-db/*": ["../handler-db/src/*"], + "@webiny/handler-db": ["../handler-db/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/api-dynamodb-to-elasticsearch/*": ["../api-dynamodb-to-elasticsearch/src/*"], + "@webiny/api-dynamodb-to-elasticsearch": ["../api-dynamodb-to-elasticsearch/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-form-builder-so-ddb-es/webiny.config.js b/packages/api-form-builder-so-ddb-es/webiny.config.js new file mode 100644 index 00000000000..d2680341ca2 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/webiny.config.js @@ -0,0 +1,8 @@ +const { watchPackage, buildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: buildPackage, + watch: watchPackage + } +}; diff --git a/packages/api-form-builder/__tests__/formSubmissionSecurity.test.js b/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts similarity index 51% rename from packages/api-form-builder/__tests__/formSubmissionSecurity.test.js rename to packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts index b7fe7176ac3..5deee83eef8 100644 --- a/packages/api-form-builder/__tests__/formSubmissionSecurity.test.js +++ b/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts @@ -46,9 +46,7 @@ const identityB = new SecurityIdentity({ displayName: "Bb" }); -const esFbIndex = "root-form-builder"; - -describe("Forms Security Test", () => { +describe("Forms Submission Security Test", () => { const handlerA = useGqlHandler({ permissions: [{ name: "content.i18n" }, { name: "fb.*" }], identity: identityA @@ -57,30 +55,106 @@ describe("Forms Security Test", () => { beforeEach(async () => { try { await handlerA.install(); - await handlerA.elasticsearch.indices.create({ index: esFbIndex }); - } catch (e) {} - }); - - afterEach(async () => { - try { - await handlerA.elasticsearch.indices.delete({ index: esFbIndex }); - } catch (e) {} + } catch (ex) { + console.log(ex.message); + } }); test(`"listFormSubmissions" only returns entries to which the identity has access to`, async () => { // Create form as Identity A const [createFormA] = await handlerA.createForm({ data: new Mock("A1-") }); + + /** + * Make sure form is created + */ + expect(createFormA).toEqual({ + data: { + formBuilder: { + createForm: { + data: { + id: expect.any(String), + formId: expect.any(String), + version: 1, + createdOn: expect.stringMatching(/^20/), + savedOn: expect.stringMatching(/^20/), + layout: [], + fields: [], + locked: false, + published: false, + publishedOn: null, + name: "A1-name", + overallStats: { + conversionRate: 0, + submissions: 0, + views: 0 + }, + stats: { + submissions: 0, + views: 0 + }, + status: "draft", + triggers: null, + settings: { + reCaptcha: { + settings: { + enabled: null, + secretKey: null, + siteKey: null + } + } + } + }, + error: null + } + } + } + }); const { data: formA } = createFormA.data.formBuilder.createForm; // Let's add some fields. - await handlerA.updateRevision({ + const [updateFormRevisionResponse] = await handlerA.updateRevision({ revision: formA.id, data: { fields: mocks.fields } }); + expect(updateFormRevisionResponse).toEqual({ + data: { + formBuilder: { + updateRevision: { + data: { + ...formA, + savedOn: expect.stringMatching(/^20/), + fields: expect.any(Array) + }, + + error: null + } + } + } + }); - await handlerA.publishRevision({ revision: formA.id }); + const [publishFormRevisionResponse] = await handlerA.publishRevision({ + revision: formA.id + }); + expect(publishFormRevisionResponse).toEqual({ + data: { + formBuilder: { + publishRevision: { + data: { + ...formA, + savedOn: expect.stringMatching(/^20/), + fields: expect.any(Array), + status: "published", + published: true, + publishedOn: expect.stringMatching(/^20/), + locked: true + }, + error: null + } + } + } + }); // Create form as Identity B (this guy can only access his own forms) const handlerB = useGqlHandler({ @@ -104,6 +178,43 @@ describe("Forms Security Test", () => { // Create submissions // NOTE: response variables are unused but left here for debugging purposes! + for (let a = 1; a <= 3; a++) { + const [createFormSubmissionResponse] = await handlerA.createFormSubmission({ + revision: formA.id, + ...new MockSubmission(`A${a}-`) + }); + + expect(createFormSubmissionResponse).toEqual({ + data: { + formBuilder: { + createFormSubmission: { + data: expect.any(Object), + error: null + } + } + } + }); + } + + for (let b = 1; b <= 2; b++) { + const [createFormSubmissionResponse] = await handlerB.createFormSubmission({ + revision: formB.id, + ...new MockSubmission(`B${b}-`) + }); + + expect(createFormSubmissionResponse).toEqual({ + data: { + formBuilder: { + createFormSubmission: { + data: expect.any(Object), + error: null + } + } + } + }); + } + + /* await handlerA.createFormSubmission({ revision: formA.id, ...new MockSubmission("A1-") @@ -128,14 +239,25 @@ describe("Forms Security Test", () => { revision: formB.id, ...new MockSubmission("B2-") }); + */ await handlerA.until( () => handlerA.listFormSubmissions({ form: formA.id }).then(([data]) => data), - ({ data }) => data.formBuilder.listFormSubmissions.data.length === 3 + ({ data }) => data.formBuilder.listFormSubmissions.data.length === 3, + { + name: "list form A submissions", + wait: 500, + tries: 20 + } ); await handlerA.until( () => handlerA.listFormSubmissions({ form: formB.id }).then(([data]) => data), - ({ data }) => data.formBuilder.listFormSubmissions.data.length === 2 + ({ data }) => data.formBuilder.listFormSubmissions.data.length === 2, + { + name: "list form B submissions", + wait: 500, + tries: 20 + } ); // Identity A should have access to submissions in Form A diff --git a/packages/api-form-builder/__tests__/forms.test.js b/packages/api-form-builder/__tests__/forms.test.ts similarity index 88% rename from packages/api-form-builder/__tests__/forms.test.js rename to packages/api-form-builder/__tests__/forms.test.ts index e10e33233f0..a70e9afb7ed 100644 --- a/packages/api-form-builder/__tests__/forms.test.js +++ b/packages/api-form-builder/__tests__/forms.test.ts @@ -9,7 +9,6 @@ jest.setTimeout(60000); describe('Form Builder "Form" Test', () => { const { until, - elasticsearch, install, installFileManager, createForm, @@ -29,9 +28,6 @@ describe('Form Builder "Form" Test', () => { exportFormSubmissions } = useGqlHandler(); - const esFbIndex = "root-form-builder"; - const esFmIndex = "root-file-manager"; - beforeEach(async () => { try { // Run FB installer @@ -43,16 +39,6 @@ describe('Form Builder "Form" Test', () => { } }); - afterEach(async () => { - try { - await elasticsearch.indices.delete({ index: esFbIndex }); - } catch (e) {} - - try { - await elasticsearch.indices.delete({ index: esFmIndex }); - } catch (e) {} - }); - test("should create a form and return it in the list of latest forms", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; @@ -118,7 +104,12 @@ describe('Form Builder "Form" Test', () => { await until( () => listForms().then(([data]) => data), - ({ data }) => data.formBuilder.listForms.data.length > 0 + ({ data }) => data.formBuilder.listForms.data.length > 0, + { + name: "after create form", + wait: 500, + tries: 20 + } ); // Create 2 new revisions @@ -131,7 +122,12 @@ describe('Form Builder "Form" Test', () => { // Wait until the new revision is indexed in Elastic as "latest" await until( () => listForms().then(([data]) => data), - ({ data }) => data.formBuilder.listForms.data[0].id === id3 + ({ data }) => data.formBuilder.listForms.data[0].id === id3, + { + name: "after create revisions", + wait: 500, + tries: 20 + } ); // Check that the form is inserted into Elastic @@ -141,12 +137,28 @@ describe('Form Builder "Form" Test', () => { expect(data1[0].id).toEqual(id3); // Delete latest revision - await deleteRevision({ revision: id3 }); + const [deleteRevisionResponse] = await deleteRevision({ revision: id3 }); + + expect(deleteRevisionResponse).toEqual({ + data: { + formBuilder: { + deleteRevision: { + data: true, + error: null + } + } + } + }); // Wait until the previous revision is indexed in Elastic as "latest" await until( () => listForms().then(([data]) => data), - ({ data }) => data.formBuilder.listForms.data[0].id === id2 + ({ data }) => data.formBuilder.listForms.data[0].id === id2, + { + name: "after delete revision 3", + wait: 500, + tries: 20 + } ); // Make sure revision #2 is now "latest" @@ -156,7 +168,18 @@ describe('Form Builder "Form" Test', () => { expect(data2[0].id).toEqual(id2); // Delete revision #1; Revision #2 should still be "latest" - await deleteRevision({ revision: id }); + const [deleteRevision1Response] = await deleteRevision({ revision: id }); + + expect(deleteRevision1Response).toEqual({ + data: { + formBuilder: { + deleteRevision: { + data: true, + error: null + } + } + } + }); // Get revisions #2 and verify it's the only remaining revision of this form const [get] = await getFormRevisions({ id: id2 }); @@ -282,8 +305,13 @@ describe('Form Builder "Form" Test', () => { // Wait until propagated to Elastic... await until( - () => listFormSubmissions({ form: id }).then(([data]) => data), - ({ data }) => data.formBuilder.listFormSubmissions.data.length === 2 + () => listFormSubmissions({ form: id, sort: "savedOn_ASC" }).then(([data]) => data), + ({ data }) => data.formBuilder.listFormSubmissions.data.length === 2, + { + name: "after create submission", + wait: 500, + tries: 200 + } ); // Load submissions diff --git a/packages/api-form-builder/__tests__/formsSecurity.test.js b/packages/api-form-builder/__tests__/formsSecurity.test.ts similarity index 95% rename from packages/api-form-builder/__tests__/formsSecurity.test.js rename to packages/api-form-builder/__tests__/formsSecurity.test.ts index e35823a0651..5277f32c42f 100644 --- a/packages/api-form-builder/__tests__/formsSecurity.test.js +++ b/packages/api-form-builder/__tests__/formsSecurity.test.ts @@ -1,7 +1,8 @@ import { SecurityIdentity } from "@webiny/api-security"; import useGqlHandler from "./useGqlHandler"; +import { SecurityPermission } from "@webiny/api-security/types"; -function Mock(prefix) { +function Mock(prefix = "") { this.name = `${prefix}name`; } @@ -46,26 +47,12 @@ const identityB = new SecurityIdentity({ displayName: "Bb" }); -const esFbIndex = "root-form-builder"; - describe("Forms Security Test", () => { const defaultHandler = useGqlHandler({ permissions: [{ name: "content.i18n" }, { name: "fb.*" }], identity: identityA }); - beforeEach(async () => { - try { - await defaultHandler.elasticsearch.indices.create({ index: esFbIndex }); - } catch (e) {} - }); - - afterEach(async () => { - try { - await defaultHandler.elasticsearch.indices.delete({ index: esFbIndex }); - } catch (e) {} - }); - test(`"listForms" only returns entries to which the identity has access to`, async () => { const { until, createForm, listForms } = defaultHandler; @@ -86,7 +73,12 @@ describe("Forms Security Test", () => { await until( () => listForms().then(([data]) => data), - ({ data }) => data.formBuilder.listForms.data.length > 0 + ({ data }) => data.formBuilder.listForms.data.length > 0, + { + name: "list forms after create", + wait: 500, + tries: 20 + } ); const insufficientPermissions = [ @@ -98,7 +90,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < insufficientPermissions.length; i++) { - const [permissions, identity] = insufficientPermissions[i]; + const [permissions, identity] = insufficientPermissions[i] as any; const { listForms } = useGqlHandler({ permissions, identity }); const [response] = await listForms(); expect(response).toEqual(NOT_AUTHORIZED_RESPONSE("listForms")); @@ -113,14 +105,19 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < sufficientPermissionsAll.length; i++) { - const [permissions, identity] = sufficientPermissionsAll[i]; + const [permissions, identity] = sufficientPermissionsAll[i] as any; const { listForms } = useGqlHandler({ permissions, identity }); await until( () => listForms().then(([data]) => data), ({ data }) => data.formBuilder.listForms.data[0].id === formB2Id && - data.formBuilder.listForms.data[3].id === formA1Id + data.formBuilder.listForms.data[3].id === formA1Id, + { + name: `list forms with sufficient permissions ${i}`, + wait: 500, + tries: 20 + } ); const [response] = await listForms(); @@ -193,7 +190,7 @@ describe("Forms Security Test", () => { test.each(insufficientPermissions)( `forbid "createForm" with %j`, - async (permissions, identity) => { + async (permissions: any, identity: SecurityIdentity) => { const { createForm } = useGqlHandler({ permissions, identity }); const [response] = await createForm({ data: new Mock() }); expect(response).toEqual(NOT_AUTHORIZED_RESPONSE("createForm")); @@ -210,7 +207,7 @@ describe("Forms Security Test", () => { test.each(sufficientPermissions)( `allow "createForm" with %j`, - async (permissions, identity) => { + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { createForm } = useGqlHandler({ permissions, identity }); const data = new Mock(`form-create-`); @@ -222,7 +219,7 @@ describe("Forms Security Test", () => { data: { ...new MockResponse({ prefix: `form-create-`, - id: response.data.formBuilder.createForm.data.id + id: expect.any(String) }) }, error: null @@ -249,7 +246,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < insufficientPermissions.length; i++) { - const [permissions, identity] = insufficientPermissions[i]; + const [permissions, identity] = insufficientPermissions[i] as any; const { updateRevision } = useGqlHandler({ permissions, identity }); const mock = new Mock(`new-updated-form-`); const [response] = await updateRevision({ revision: formId, data: mock }); @@ -265,7 +262,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < sufficientPermissions.length; i++) { - const [permissions, identity] = sufficientPermissions[i]; + const [permissions, identity] = sufficientPermissions[i] as any; const { updateRevision } = useGqlHandler({ permissions, identity }); const mock = new Mock(`new-updated-form-`); const [response] = await updateRevision({ revision: formId, data: mock }); @@ -301,7 +298,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < insufficientPermissions.length; i++) { - const [permissions, identity] = insufficientPermissions[i]; + const [permissions, identity] = insufficientPermissions[i] as any; const { getForm } = useGqlHandler({ permissions, identity }); const [response] = await getForm({ revision: formId }); expect(response).toEqual(NOT_AUTHORIZED_RESPONSE("getForm")); @@ -316,7 +313,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < sufficientPermissions.length; i++) { - const [permissions, identity] = sufficientPermissions[i]; + const [permissions, identity] = sufficientPermissions[i] as any; const { getForm } = useGqlHandler({ permissions, identity }); const [response] = await getForm({ revision: formId }); expect(response).toMatchObject({ @@ -348,7 +345,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < insufficientPermissions.length; i++) { - const [permissions, identity] = insufficientPermissions[i]; + const [permissions, identity] = insufficientPermissions[i] as any; const { deleteForm } = useGqlHandler({ permissions, identity }); const [response] = await deleteForm({ id: formId }); expect(response).toEqual(NOT_AUTHORIZED_RESPONSE("deleteForm")); @@ -361,7 +358,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < sufficientPermissions.length; i++) { - const [permissions, identity] = sufficientPermissions[i]; + const [permissions, identity] = sufficientPermissions[i] as any; const { deleteForm } = useGqlHandler({ permissions, identity }); const [response] = await deleteForm({ id: formId }); expect(response).toMatchObject({ @@ -394,7 +391,7 @@ describe("Forms Security Test", () => { test.each(insufficientPublishPermissions)( `do not allow "publishForm" with %j`, - async (permissions, identity) => { + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { createForm } = defaultHandler; const mock = new Mock("publishRevision-form-"); @@ -415,7 +412,7 @@ describe("Forms Security Test", () => { test.each(sufficientPublishPermissions)( `allow "publishForm" with %j`, - async (permissions, identity) => { + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { createForm } = defaultHandler; const mock = new Mock("publishRevision-form-"); @@ -484,7 +481,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < insufficientPermissions.length; i++) { - const [permissions, identity] = insufficientPermissions[i]; + const [permissions, identity] = insufficientPermissions[i] as any; const { createRevisionFrom } = useGqlHandler({ permissions, identity }); const [response] = await createRevisionFrom({ revision: formId }); expect(response).toEqual(NOT_AUTHORIZED_RESPONSE("createRevisionFrom")); @@ -499,7 +496,7 @@ describe("Forms Security Test", () => { ]; for (let i = 0; i < sufficientPermissions.length; i++) { - const [permissions, identity] = sufficientPermissions[i]; + const [permissions, identity] = sufficientPermissions[i] as any; const { createRevisionFrom } = useGqlHandler({ permissions, identity }); const [response] = await createRevisionFrom({ revision: formId }); diff --git a/packages/api-form-builder/__tests__/graphql/fileManagerSettings.js b/packages/api-form-builder/__tests__/graphql/fileManagerSettings.ts similarity index 100% rename from packages/api-form-builder/__tests__/graphql/fileManagerSettings.js rename to packages/api-form-builder/__tests__/graphql/fileManagerSettings.ts diff --git a/packages/api-form-builder/__tests__/graphql/formBuilderSettings.js b/packages/api-form-builder/__tests__/graphql/formBuilderSettings.ts similarity index 100% rename from packages/api-form-builder/__tests__/graphql/formBuilderSettings.js rename to packages/api-form-builder/__tests__/graphql/formBuilderSettings.ts diff --git a/packages/api-form-builder/__tests__/graphql/formSubmission.js b/packages/api-form-builder/__tests__/graphql/formSubmission.ts similarity index 94% rename from packages/api-form-builder/__tests__/graphql/formSubmission.js rename to packages/api-form-builder/__tests__/graphql/formSubmission.ts index 2fde1f84875..986bf8cf6ff 100644 --- a/packages/api-form-builder/__tests__/graphql/formSubmission.js +++ b/packages/api-form-builder/__tests__/graphql/formSubmission.ts @@ -67,7 +67,7 @@ export const EXPORT_FORM_SUBMISSIONS = /* GraphQL */ ` `; export const LIST_FROM_SUBMISSIONS = /* GraphQL */ ` - query ListFormSubmissions($form: ID!, $sort: FbSubmissionSortInput, $limit: Int, $after: String) { + query ListFormSubmissions($form: ID!, $sort: [FbSubmissionSort!], $limit: Int, $after: String) { formBuilder { listFormSubmissions(form: $form, sort: $sort, limit: $limit, after: $after) { data ${DATA_FIELD} diff --git a/packages/api-form-builder/__tests__/graphql/forms.js b/packages/api-form-builder/__tests__/graphql/forms.ts similarity index 97% rename from packages/api-form-builder/__tests__/graphql/forms.js rename to packages/api-form-builder/__tests__/graphql/forms.ts index 3b3ecc9a7bb..c2932f6dca9 100644 --- a/packages/api-form-builder/__tests__/graphql/forms.js +++ b/packages/api-form-builder/__tests__/graphql/forms.ts @@ -1,12 +1,18 @@ export const FORM_DATA_FIELD = /* GraphQL */ ` { id + formId + version savedOn createdOn publishedOn version name layout + fields { + fieldId + type + } settings { reCaptcha { settings { diff --git a/packages/api-form-builder/__tests__/helpers.ts b/packages/api-form-builder/__tests__/helpers.ts new file mode 100644 index 00000000000..142c9f85276 --- /dev/null +++ b/packages/api-form-builder/__tests__/helpers.ts @@ -0,0 +1,43 @@ +type UntilOptions = { + name?: string; + tries?: number; + wait?: number; +}; + +export const until = async (execute, until, options: UntilOptions = {}) => { + const { name = "NO_NAME", tries = 5, wait = 300 } = options; + + let result; + let triesCount = 0; + + while (true) { + result = await execute(); + + let done; + try { + done = await until(result); + } catch {} + + if (done) { + return result; + } + + triesCount++; + if (triesCount === tries) { + break; + } + + // Wait. + await new Promise((resolve: any) => { + setTimeout(() => resolve(), wait); + }); + } + + throw new Error( + `[${name}] Tried ${tries} times but failed. Last result that was received: ${JSON.stringify( + result, + null, + 2 + )}` + ); +}; diff --git a/packages/api-form-builder/__tests__/mocks/form.mocks.js b/packages/api-form-builder/__tests__/mocks/form.mocks.ts similarity index 100% rename from packages/api-form-builder/__tests__/mocks/form.mocks.js rename to packages/api-form-builder/__tests__/mocks/form.mocks.ts diff --git a/packages/api-form-builder/__tests__/settings.test.js b/packages/api-form-builder/__tests__/settings.test.ts similarity index 67% rename from packages/api-form-builder/__tests__/settings.test.js rename to packages/api-form-builder/__tests__/settings.test.ts index 58cd6b36f73..764543af83c 100644 --- a/packages/api-form-builder/__tests__/settings.test.js +++ b/packages/api-form-builder/__tests__/settings.test.ts @@ -5,9 +5,9 @@ describe("Settings Test", () => { test(`Should not be able to get & update settings before "install"`, async () => { // Should not have any settings without install - let [response] = await getSettings(); + const [getSettingsResponse] = await getSettings(); - expect(response).toEqual({ + expect(getSettingsResponse).toEqual({ data: { formBuilder: { getSettings: { @@ -22,8 +22,8 @@ describe("Settings Test", () => { } }); - [response] = await updateSettings({ data: { domain: "main" } }); - expect(response).toEqual({ + const [updateSettingsResponse] = await updateSettings({ data: { domain: "main" } }); + expect(updateSettingsResponse).toEqual({ data: { formBuilder: { updateSettings: { @@ -41,9 +41,9 @@ describe("Settings Test", () => { test("Should be able to install `Form Builder`", async () => { // "isInstalled" should return false prior "install" - let [response] = await isInstalled(); + const [isInstalledResponse] = await isInstalled(); - expect(response).toEqual({ + expect(isInstalledResponse).toEqual({ data: { formBuilder: { version: null @@ -52,9 +52,9 @@ describe("Settings Test", () => { }); // Let's install the `Form builder` - [response] = await install({ domain: "http://localhost:3001" }); + const [installResponse] = await install({ domain: "http://localhost:3001" }); - expect(response).toEqual({ + expect(installResponse).toEqual({ data: { formBuilder: { install: { @@ -66,7 +66,7 @@ describe("Settings Test", () => { }); // "isInstalled" should return true after "install" - [response] = await isInstalled(); + const [response] = await isInstalled(); expect(response).toEqual({ data: { @@ -79,9 +79,9 @@ describe("Settings Test", () => { test(`Should be able to get & update settings after "install"`, async () => { // Let's install the `Form builder` - let [response] = await install({ domain: "http://localhost:3001" }); + const [installResponse] = await install({ domain: "http://localhost:3001" }); - expect(response).toEqual({ + expect(installResponse).toEqual({ data: { formBuilder: { install: { @@ -93,9 +93,9 @@ describe("Settings Test", () => { }); // Should not have any settings without install - [response] = await getSettings(); + const [getSettingsResponse] = await getSettings(); - expect(response).toEqual({ + expect(getSettingsResponse).toEqual({ data: { formBuilder: { getSettings: { @@ -113,8 +113,10 @@ describe("Settings Test", () => { } }); - [response] = await updateSettings({ data: { domain: "http://localhost:5001" } }); - expect(response).toEqual({ + const [updateSettingsResponse] = await updateSettings({ + data: { domain: "http://localhost:5001" } + }); + expect(updateSettingsResponse).toEqual({ data: { formBuilder: { updateSettings: { @@ -131,5 +133,25 @@ describe("Settings Test", () => { } } }); + + const [getSettingsAfterUpdateResponse] = await getSettings(); + + expect(getSettingsAfterUpdateResponse).toEqual({ + data: { + formBuilder: { + getSettings: { + data: { + domain: "http://localhost:5001", + reCaptcha: { + enabled: null, + secretKey: null, + siteKey: null + } + }, + error: null + } + } + } + }); }); }); diff --git a/packages/api-form-builder/__tests__/settingsSecurity.test.js b/packages/api-form-builder/__tests__/settingsSecurity.test.ts similarity index 86% rename from packages/api-form-builder/__tests__/settingsSecurity.test.js rename to packages/api-form-builder/__tests__/settingsSecurity.test.ts index a0bc82541a3..81a4b2dfa22 100644 --- a/packages/api-form-builder/__tests__/settingsSecurity.test.js +++ b/packages/api-form-builder/__tests__/settingsSecurity.test.ts @@ -1,5 +1,6 @@ import { SecurityIdentity } from "@webiny/api-security"; import useGqlHandler from "./useGqlHandler"; +import { SecurityPermission } from "@webiny/api-security/types"; const NOT_AUTHORIZED_RESPONSE = operation => ({ data: { @@ -16,8 +17,6 @@ const NOT_AUTHORIZED_RESPONSE = operation => ({ } }); -const esFbIndex = "root-form-builder"; - describe("Form Builder Settings Security Test", () => { const identityA = new SecurityIdentity({ id: "a", @@ -32,16 +31,7 @@ describe("Form Builder Settings Security Test", () => { }); beforeEach(async () => { - try { - await defaultHandler.install({ domain: "localhost:5000" }); - await defaultHandler.elasticsearch.indices.create({ index: esFbIndex }); - } catch (e) {} - }); - - afterEach(async () => { - try { - await defaultHandler.elasticsearch.indices.delete({ index: esFbIndex }); - } catch (e) {} + await defaultHandler.install({ domain: "localhost:5000" }); }); const insufficientUpdatePermissions = [ @@ -52,7 +42,7 @@ describe("Form Builder Settings Security Test", () => { test.each(insufficientUpdatePermissions)( `should forbid "updateSettings" with %j and %j`, - async (permissions, identity) => { + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { updateSettings } = useGqlHandler({ permissions, identity }); const [response] = await updateSettings(); expect(response).toEqual(NOT_AUTHORIZED_RESPONSE("updateSettings")); @@ -65,8 +55,8 @@ describe("Form Builder Settings Security Test", () => { ]; test.each(sufficientUpdatePermissions)( - `should allow "updateSettings" with invalid permissions`, - async (permissions, identity) => { + `should allow "updateSettings" with sufficient permissions`, + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { updateSettings } = useGqlHandler({ permissions, identity }); const [response] = await updateSettings({ data: { domain: "localhost:3000" } }); expect(response).toMatchObject({ @@ -97,7 +87,7 @@ describe("Form Builder Settings Security Test", () => { test.each(insufficientGetPermissions)( `should forbid "getSettings" with %j and %j`, - async (permissions, identity) => { + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { install } = defaultHandler; // Let's install the "Form Builder" app first. await install({ domain: "localhost:5000" }); @@ -115,7 +105,7 @@ describe("Form Builder Settings Security Test", () => { test.each(sufficientGetPermissions)( `should allow "getSettings" with %j and %j`, - async (permissions, identity) => { + async (permissions: SecurityPermission[], identity: SecurityIdentity) => { const { install } = defaultHandler; // Let's install the "Form Builder" app first. await install({ domain: "localhost:5000" }); diff --git a/packages/api-form-builder/__tests__/useGqlHandler.js b/packages/api-form-builder/__tests__/useGqlHandler.ts similarity index 71% rename from packages/api-form-builder/__tests__/useGqlHandler.js rename to packages/api-form-builder/__tests__/useGqlHandler.ts index e431e681ed3..5833137855e 100644 --- a/packages/api-form-builder/__tests__/useGqlHandler.js +++ b/packages/api-form-builder/__tests__/useGqlHandler.ts @@ -6,19 +6,12 @@ import tenancyPlugins from "@webiny/api-tenancy"; import securityPlugins from "@webiny/api-security"; import fileManagerPlugins from "@webiny/api-file-manager/plugins"; import fileManagerDynamoDbElasticPlugins from "@webiny/api-file-manager-ddb-es"; -import dbPlugins from "@webiny/handler-db"; import i18nContext from "@webiny/api-i18n/graphql/context"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; import i18nContentPlugins from "@webiny/api-i18n-content/plugins"; import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; -import { DynamoDbDriver } from "@webiny/db-dynamodb"; -import { DocumentClient } from "aws-sdk/clients/dynamodb"; import { SecurityIdentity } from "@webiny/api-security"; -import elasticsearchClientContextPlugin from "@webiny/api-elasticsearch"; -import { simulateStream } from "@webiny/project-utils/testing/dynamodb"; -import dynamoToElastic from "@webiny/api-dynamodb-to-elasticsearch/handler"; -import { Client } from "@elastic/elasticsearch"; -import formBuilderPlugins from "../src/plugins"; +import { createFormBuilder } from "~/index"; // Graphql import { INSTALL as INSTALL_FILE_MANAGER } from "./graphql/fileManagerSettings"; import { @@ -46,71 +39,38 @@ import { LIST_FROM_SUBMISSIONS, EXPORT_FORM_SUBMISSIONS } from "./graphql/formSubmission"; +import { SecurityPermission } from "@webiny/api-security/types"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { until } from "./helpers"; const defaultTenant = { id: "root", name: "Root", parent: null }; -const until = async (execute, until, options = {}) => { - const tries = options.tries ?? 5; - const wait = options.wait ?? 1000; - - let result; - let triesCount = 0; - - while (true) { - result = await execute(); - - let done; - try { - done = await until(result); - } catch {} - - if (done) { - return result; - } - - triesCount++; - if (triesCount === tries) { - break; - } - - // Wait. - await new Promise(resolve => { - setTimeout(() => resolve(), wait); - }); +export interface UseGqlHandlerParams { + permissions?: SecurityPermission[]; + identity?: SecurityIdentity; + plugins?: any; + tenant?: Tenant; +} + +export default (params: UseGqlHandlerParams = {}) => { + const { permissions, identity, tenant, plugins = [] } = params; + // @ts-ignore + if (typeof __getStorageOperations !== "function") { + throw new Error(`There is no global "__getStorageOperations" function.`); + } + // @ts-ignore + const { createStorageOperations, getGlobalPlugins } = __getStorageOperations(); + if (typeof createStorageOperations !== "function") { + throw new Error( + `A product of "__getStorageOperations" must be a function to initialize storage operations.` + ); + } + if (typeof getGlobalPlugins === "function") { + plugins.push(...getGlobalPlugins()); } - - throw new Error( - `Tried ${tries} times but failed. Last result that was received: ${JSON.stringify( - result, - null, - 2 - )}` - ); -}; - -const ELASTICSEARCH_PORT = process.env.ELASTICSEARCH_PORT || "9200"; - -export default ({ permissions, identity, tenant } = {}) => { - const documentClient = new DocumentClient({ - convertEmptyValues: true, - endpoint: process.env.MOCK_DYNAMODB_ENDPOINT, - sslEnabled: false, - region: "local" - }); - - const elasticsearchClientContext = elasticsearchClientContextPlugin({ - endpoint: `http://localhost:${ELASTICSEARCH_PORT}` - }); - - // Intercept DocumentClient operations and trigger dynamoToElastic function (almost like a DynamoDB Stream trigger) - simulateStream(documentClient, createHandler(elasticsearchClientContext, dynamoToElastic())); const handler = createHandler( - dbPlugins({ - table: "FormBuilder", - driver: new DynamoDbDriver({ documentClient }) - }), - elasticsearchClientContext, + ...plugins, graphqlHandlerPlugins(), tenancyPlugins(), securityPlugins(), @@ -128,7 +88,13 @@ export default ({ permissions, identity, tenant } = {}) => { mockLocalesPlugins(), fileManagerPlugins(), fileManagerDynamoDbElasticPlugins(), - formBuilderPlugins(), + /** + * We need to create the form builder API app. + * It requires storage operations and plugins from the storage operations. + */ + createFormBuilder({ + storageOperations: createStorageOperations() + }), { type: "security-authorization", name: "security-authorization", @@ -162,8 +128,9 @@ export default ({ permissions, identity, tenant } = {}) => { } }; }, - // eslint-disable-next-line - async delete(args) {} + async delete() { + // dummy + } } ); @@ -182,10 +149,6 @@ export default ({ permissions, identity, tenant } = {}) => { return { until, - elasticsearch: new Client({ - hosts: [`http://localhost:${ELASTICSEARCH_PORT}`], - node: `http://localhost:${ELASTICSEARCH_PORT}` - }), sleep: (ms = 100) => { return new Promise(resolve => { setTimeout(resolve, ms); @@ -194,17 +157,17 @@ export default ({ permissions, identity, tenant } = {}) => { handler, invoke, // Form builder settings - async updateSettings(variables) { + async updateSettings(variables = {}) { return invoke({ body: { query: UPDATE_SETTINGS, variables } }); }, - async getSettings(variables) { + async getSettings(variables = {}) { return invoke({ body: { query: GET_SETTINGS, variables } }); }, // Install Form builder - async install(variables) { + async install(variables = {}) { return invoke({ body: { query: INSTALL, variables } }); }, - async isInstalled(variables) { + async isInstalled(variables = {}) { return invoke({ body: { query: IS_INSTALLED, variables } }); }, // Install File Manager diff --git a/packages/api-form-builder/jest.setup.js b/packages/api-form-builder/jest.setup.js new file mode 100644 index 00000000000..373b7f0688a --- /dev/null +++ b/packages/api-form-builder/jest.setup.js @@ -0,0 +1,14 @@ +const base = require("../../jest.config.base"); +const items = require("@webiny/project-utils/testing/presets")( + ["@webiny/api-form-builder", "storage-operations"], + "storage-operations" +); + +module.exports = items.map(item => { + return { + ...base({ path: __dirname }, item.presets), + name: item.name, + displayName: item.name, + keywords: item.package.keywords + }; +}); diff --git a/packages/api-form-builder/package.json b/packages/api-form-builder/package.json index c3504cd79bd..6e37fc61185 100644 --- a/packages/api-form-builder/package.json +++ b/packages/api-form-builder/package.json @@ -2,6 +2,9 @@ "name": "@webiny/api-form-builder", "version": "5.15.0", "main": "index.js", + "keywords": [ + "fb:base" + ], "repository": { "type": "git", "url": "https://github.com/webiny/webiny-js.git", @@ -24,13 +27,13 @@ "@webiny/api-security": "^5.15.0", "@webiny/api-tenancy": "^5.15.0", "@webiny/api-upgrade": "^5.15.0", - "@webiny/db-dynamodb": "^5.15.0", "@webiny/error": "^5.15.0", "@webiny/handler": "^5.15.0", "@webiny/handler-aws": "^5.15.0", - "@webiny/handler-db": "^5.15.0", "@webiny/handler-graphql": "^5.15.0", "@webiny/plugins": "^5.15.0", + "@webiny/pubsub": "^5.15.0", + "@webiny/utils": "^5.15.0", "@webiny/validation": "^5.15.0", "commodo-fields-object": "^1.0.6", "got": "^9.6.0", @@ -55,6 +58,7 @@ "jest": "^26.6.3", "jest-dynalite": "^3.2.0", "rimraf": "^3.0.2", + "ttypescript": "^1.5.12", "typescript": "^4.1.3" }, "publishConfig": { diff --git a/packages/api-form-builder/src/index.ts b/packages/api-form-builder/src/index.ts new file mode 100644 index 00000000000..3f610b768d2 --- /dev/null +++ b/packages/api-form-builder/src/index.ts @@ -0,0 +1,24 @@ +import createCruds from "./plugins/crud"; +import graphql from "./plugins/graphql"; +import upgrades from "./plugins/upgrades"; +import triggerHandlers from "./plugins/triggers/triggerHandlers"; +import validators from "./plugins/validators"; +import formsGraphQL from "./plugins/graphql/form"; +import formSettingsGraphQL from "./plugins/graphql/formSettings"; +import { FormBuilderStorageOperations } from "~/types"; + +export interface Params { + storageOperations: FormBuilderStorageOperations; +} + +export const createFormBuilder = (params: Params) => { + return [ + createCruds(params), + graphql, + upgrades, + triggerHandlers, + validators, + formsGraphQL, + formSettingsGraphQL + ]; +}; diff --git a/packages/api-form-builder/src/plugins/crud/defaults.ts b/packages/api-form-builder/src/plugins/crud/defaults.ts deleted file mode 100644 index 51c31344a4f..00000000000 --- a/packages/api-form-builder/src/plugins/crud/defaults.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Context } from "@webiny/handler/types"; -import { SecurityContext } from "@webiny/api-security/types"; -import { TenancyContext } from "@webiny/api-tenancy/types"; - -export default { - db: { - table: process.env.DB_TABLE_FORM_BUILDER, - keys: [ - { - primary: true, - unique: true, - name: "primary", - fields: [{ name: "PK" }, { name: "SK" }] - } - ] - }, - esDb: { - table: process.env.DB_TABLE_ELASTICSEARCH, - keys: [ - { - primary: true, - unique: true, - name: "primary", - fields: [{ name: "PK" }, { name: "SK" }] - } - ] - }, - es(context: Context) { - const tenant = context.tenancy.getCurrentTenant(); - if (!tenant) { - throw new Error("Tenant missing."); - } - - const sharedIndex = process.env.ELASTICSEARCH_SHARED_INDEXES === "true"; - const index = `${sharedIndex ? "root" : tenant.id}-form-builder`; - - const prefix = process.env.ELASTIC_SEARCH_INDEX_PREFIX; - if (prefix) { - return { index: prefix + index }; - } - return { index }; - } -}; diff --git a/packages/api-form-builder/src/plugins/crud/forms.crud.ts b/packages/api-form-builder/src/plugins/crud/forms.crud.ts index d05214afd36..218cc4b2f15 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.crud.ts @@ -1,1300 +1,631 @@ -import { ContextPlugin } from "@webiny/handler/types"; import mdbid from "mdbid"; import slugify from "slugify"; -import pick from "lodash/pick"; -import fetch from "node-fetch"; import { NotFoundError } from "@webiny/handler-graphql"; -import { NotAuthorizedError } from "@webiny/api-security"; import * as utils from "./utils"; -import { checkOwnership, encodeCursor } from "./utils"; -import defaults from "./defaults"; +import { checkOwnership } from "./utils"; import * as models from "./forms.models"; -import { FbForm, FbSubmission, FormBuilderContext } from "../../types"; +import { + FbForm, + FbFormPermission, + FbFormStats, + FormBuilder, + FormBuilderContext, + FormBuilderStorageOperationsListFormsParams, + FormsCRUD +} from "~/types"; import WebinyError from "@webiny/error"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { I18NLocale } from "@webiny/api-i18n/types"; +import { createIdentifier } from "@webiny/utils"; + +export interface Params { + tenant: Tenant; + locale: I18NLocale; + context: FormBuilderContext; +} + +export const createFormsCrud = (params: Params): FormsCRUD => { + const { context, tenant, locale } = params; + + return { + async getForm(this: FormBuilder, id, options) { + let permission: FbFormPermission = undefined; + if (!options || options.auth !== false) { + permission = await utils.checkBaseFormPermissions(context, { rwd: "r" }); + } -const TYPE_FORM = "fb.form"; -const TYPE_FORM_LATEST = "fb.form.latest"; -const TYPE_FORM_LATEST_PUBLISHED = "fb.form.latestPublished"; -const TYPE_FORM_SUBMISSION = "fb.formSubmission"; - -const getESDataForLatestRevision = (form: FbForm, context: FormBuilderContext) => ({ - __type: "fb.form", - tenant: context.tenancy.getCurrentTenant().id, - webinyVersion: context.WEBINY_VERSION, - id: form.id, - createdOn: form.createdOn, - savedOn: form.savedOn, - name: form.name, - slug: form.slug, - published: form.published, - publishedOn: form.publishedOn, - version: form.version, - locked: form.locked, - status: form.status, - createdBy: form.createdBy, - ownedBy: form.ownedBy, - locale: context.i18nContent.locale.code -}); - -const zeroPad = version => `${version}`.padStart(4, "0"); - -type DbItem = T & { - PK: string; - SK: string; - TYPE: string; -}; - -export default { - type: "context", - apply(context: FormBuilderContext) { - const { db, i18nContent, elasticsearch, tenancy } = context; - - const PK_FORM = formId => `${utils.getPKPrefix(context)}F#${formId}`; - const SK_FORM_REVISION = version => { - return typeof version === "string" ? `REV#${version}` : `REV#${zeroPad(version)}`; - }; - const SK_FORM_LATEST = () => "L"; - const SK_FORM_LATEST_PUBLISHED = () => "LP"; - const SK_SUBMISSION = submissionId => `FS#${submissionId}`; - - context.formBuilder = { - ...context.formBuilder, - forms: { - async getForm(id) { - const permission = await utils.checkBaseFormPermissions(context, { rwd: "r" }); - - const [uniqueId, version] = id.split("#"); - - const [[form]] = await db.read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_REVISION(version) - } - }); - - utils.checkOwnership(form, permission, context); - - return form; - }, - async getFormStats(id) { - // We don't need to check permissions here, as this method is only called - // as a resolver to an `FbForm` GraphQL type, and we already check permissions - // and ownership when resolving the form in `getForm`. - const allRevisions = await this.getFormRevisions(id); - - // Then calculate the stats - const stats = { - submissions: 0, - views: 0, - conversionRate: 0 - }; - - for (let i = 0; i < allRevisions.length; i++) { - const revision = allRevisions[i]; - stats.views += revision.stats.views; - stats.submissions += revision.stats.submissions; - } - - let conversionRate = 0; - if (stats.views > 0) { - conversionRate = parseFloat( - ((stats.submissions / stats.views) * 100).toFixed(2) - ); - } - - return { - ...stats, - conversionRate - }; - }, - async listForms() { - const permission = await utils.checkBaseFormPermissions(context, { rwd: "r" }); - - const must: any = [ - { term: { "__type.keyword": "fb.form" } }, - { term: { "locale.keyword": i18nContent.locale.code } } - ]; - - // Only get records which are owned by current user. - if (permission.own === true) { - const identity = context.security.getIdentity(); - must.push({ - term: { "ownedBy.id.keyword": identity.id } - }); - } - - // When ES index is shared between tenants, we need to filter records by tenant ID - const sharedIndex = process.env.ELASTICSEARCH_SHARED_INDEXES === "true"; - if (sharedIndex) { - const tenant = tenancy.getCurrentTenant(); - must.push({ term: { "tenant.keyword": tenant.id } }); - } - - const body = { - query: { - bool: { - must - } - }, - sort: [ - { - savedOn: { - order: "desc", - - unmapped_type: "date" - } - } - ], - size: 1000 - }; - - // Get "latest" form revisions from Elasticsearch. - try { - const response = await elasticsearch.search({ - ...defaults.es(context), - body - }); - - return response.body.hits.hits.map(item => item._source); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not perform search.", - ex.code || "ELASTICSEARCH_ERROR", - { - body - } - ); - } - }, - async getFormRevisions(id) { - const permission = await utils.checkBaseFormPermissions(context, { rwd: "r" }); - const [uniqueId] = id.split("#"); - - const [forms] = await db.read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: { $beginsWith: "REV#" }, - sort: { SK: -1 } - } - }); - - utils.checkOwnership(forms[0], permission, context); - - return forms.sort((a, b) => b.version - a.version); - }, - async getPublishedFormRevisionById(revisionId) { - const [uniqueId, version] = revisionId.split("#"); - - const [[form]] = await db.read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_REVISION(version) - } - }); - - if (!form || !form.published) { - throw new NotFoundError(`Form "${revisionId}" was not found!`); - } - - return form; - }, - async getLatestPublishedFormRevision(formId) { - // Make sure we have a unique form ID, and not a revision ID - const [uniqueId] = formId.split("#"); - - const [[latestPublishedItem]] = await db.read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST_PUBLISHED() - } - }); - - if (!latestPublishedItem) { - throw new NotFoundError(`Form "${formId}" was not found!`); - } - - const [[form]] = await db.read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_REVISION(latestPublishedItem.version) - } - }); - - return form; - }, - async createForm(data) { - await utils.checkBaseFormPermissions(context, { rwd: "w" }); - - const identity = context.security.getIdentity(); - await new models.FormCreateDataModel().populate(data).validate(); - - // Forms are identified by a common parent ID + Revision number - const [uniqueId, version] = [mdbid(), 1]; - const id = `${uniqueId}#${zeroPad(version)}`; - - const form: FbForm = { + let form: FbForm = undefined; + try { + form = await this.storageOperations.getForm({ + where: { id, - locale: i18nContent.locale.code, - tenant: tenancy.getCurrentTenant().id, - savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - ownedBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - name: data.name, - slug: [slugify(data.name), uniqueId].join("-").toLowerCase(), - version, - locked: false, - published: false, - publishedOn: null, - status: utils.getStatus({ published: false, locked: false }), - stats: { - views: 0, - submissions: 0 - }, - // Will be added via a "update" - fields: [], - layout: [], - settings: await new models.FormSettingsModel().toJSON(), - triggers: null - }; - - const FORM_PK = PK_FORM(uniqueId); - - await db - .batch() - .create({ - ...defaults.db, - data: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version), - TYPE: TYPE_FORM, - ...form - } - }) - .create({ - ...defaults.db, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - TYPE: TYPE_FORM_LATEST, - id, - version - } - }) - .create({ - ...defaults.esDb, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - index: defaults.es(context).index, - data: getESDataForLatestRevision(form, context) - } - }) - .execute(); - - return form; - }, - async updateForm(id, data) { - const permission = await utils.checkBaseFormPermissions(context, { rwd: "w" }); - const updateData = new models.FormUpdateDataModel().populate(data); - await updateData.validate(); - - const [uniqueId, version] = id.split("#"); - const FORM_PK = PK_FORM(uniqueId); - - const [[[form]], [[latestForm]]] = await db - .batch() - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - } - }) - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - } - }) - .execute(); - - if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); + tenant: tenant.id, + locale: locale.code } - - checkOwnership(form, permission, context); - - const newData = Object.assign(await updateData.toJSON({ onlyDirty: true }), { - savedOn: new Date().toISOString() - }); - Object.assign(form, newData); - - // Finally save it to DB - const batch = db.batch().update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - }, - data: form - }); - - // Update form in "Elastic Search" - if (latestForm.id === id) { - batch.update({ - ...defaults.esDb, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - index: defaults.es(context).index, - data: getESDataForLatestRevision(form, context) - } - }); - } - - await batch.execute(); - - return form; - }, - async deleteForm(id) { - const permission = await utils.checkBaseFormPermissions(context, { rwd: "d" }); - - const [uniqueId] = id.split("#"); - - const [items] = await db.read>({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: { $gt: " " } - } - }); - - if (!items.length) { - throw new NotFoundError(`Form ${id} was not found!`); + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load form.", + ex.code || "GET_FORM_ERROR", + { + id } + ); + } - const form = items.find(item => item.TYPE === TYPE_FORM); - checkOwnership(form, permission, context); + if (!form) { + throw new NotFoundError("Form not found."); + } else if (permission) { + utils.checkOwnership(form, permission, context); + } - // Delete all items in batches of 25 - await utils.paginateBatch(items, 25, async items => { - await db - .batch() - .delete( - ...items.map(item => ({ - ...defaults.db, - query: { PK: item.PK, SK: item.SK } - })) - ) - .execute(); - }); + return form; + }, + async getFormStats(this: FormBuilder, id) { + /** + * We don't need to check permissions here, as this method is only called + * as a resolver to an `FbForm` GraphQL type, and we already check permissions + * and ownership when resolving the form in `getForm`. + */ + const revisions = await this.getFormRevisions(id, { + auth: false + }); + + /** + * Then calculate the stats + */ + const stats: FbFormStats = { + submissions: 0, + views: 0, + conversionRate: 0 + }; + + for (const form of revisions) { + stats.views += form.stats.views; + stats.submissions += form.stats.submissions; + } - // Delete items from "Elastic Search" - await db.delete({ - ...defaults.esDb, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST() - } - }); + let conversionRate = 0; + if (stats.views > 0) { + conversionRate = parseFloat(((stats.submissions / stats.views) * 100).toFixed(2)); + } - return true; + return { + ...stats, + conversionRate + }; + }, + async listForms(this: FormBuilder) { + const permission = await utils.checkBaseFormPermissions(context, { rwd: "r" }); + + const listFormParams: FormBuilderStorageOperationsListFormsParams = { + where: { + tenant: tenant.id, + locale: locale.code }, - async deleteRevision(id) { - const permission = await utils.checkBaseFormPermissions(context, { rwd: "d" }); - - const [uniqueId, version] = id.split("#"); - const FORM_PK = PK_FORM(uniqueId); - - // Load form, latest form and latest published form records - const [[[form]], [[lForm]], [[lpForm]]] = await db - .batch() - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - } - }) - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - } - }) - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED() - } - }) - .execute(); + limit: 10000, + sort: ["savedOn_DESC"], + after: null + }; + + if (permission.own === true) { + const identity = context.security.getIdentity(); + listFormParams.where.ownedBy = identity.id; + } - if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); + try { + const { items } = await this.storageOperations.listForms(listFormParams); + + return items; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list all forms by given params", + ex.code || "LIST_FORMS_ERROR", + { + ...(ex.data || {}), + params: listFormParams } + ); + } + }, + async getFormRevisions(this: FormBuilder, id, options) { + let permission: FbFormPermission = null; + if (!options || options.auth !== false) { + permission = await utils.checkBaseFormPermissions(context, { rwd: "r" }); + } - checkOwnership(form, permission, context); - - const batch = db.batch().delete({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - } - }); - - if (lForm.id === id || (lpForm && lpForm.id === id)) { - // Get all form revisions - const [revisions] = await db.read({ - ...defaults.db, - query: { PK: FORM_PK, SK: { $beginsWith: "REV#" } }, - sort: { SK: -1 } - }); - - // Update or delete the "latest published" record - if (lpForm && lpForm.id === id) { - const publishedRevision = revisions - .filter(rev => rev.id !== id && rev.publishedOn !== null) - .sort( - (a, b) => - new Date(b.publishedOn).getTime() - - new Date(a.publishedOn).getTime() - ) - .shift(); - - if (publishedRevision) { - batch.update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED(), - TYPE: TYPE_FORM_LATEST_PUBLISHED, - id: publishedRevision.id, - version: publishedRevision.version - } - }); - } else { - batch.delete({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED() - } - }); - } - } - - if (lForm.id === id) { - // Find revision right before the one being deleted - const prevRevision = revisions - .filter(rev => rev.version < form.version) - .sort((a, b) => b.version - a.version) - .shift(); - - if (!prevRevision && revisions.length === 1) { - // Means we're deleting the last revision, so we need to delete the whole form. - return this.deleteForm(uniqueId); - } - - batch - .update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - TYPE: TYPE_FORM_LATEST, - id: prevRevision.id, - version: prevRevision.version - } - }) - .update({ - ...defaults.esDb, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - index: defaults.es(context).index, - data: getESDataForLatestRevision(prevRevision, context) - } - }); - } + try { + const forms = await this.storageOperations.listFormRevisions({ + where: { + id, + tenant: tenant.id, + locale: locale.code } - - await batch.execute(); - - return true; - }, - async publishForm(id) { - const permission = await utils.checkBaseFormPermissions(context, { - rwd: "r", - pw: "p" - }); - - const [uniqueId, version] = id.split("#"); - - const [[[form]], [[latestForm]]] = await db - .batch() - .read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_REVISION(version) - } - }) - .read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST() - } - }) - .execute(); - - if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); + }); + if (forms.length === 0 || !permission) { + return forms; + } + utils.checkOwnership(forms[0], permission, context); + + return forms; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list form revisions.", + ex.code || "LIST_FORM_REVISIONS_ERROR", + { + id } + ); + } + }, + async getPublishedFormRevisionById(this: FormBuilder, id) { + const [formId, version] = id.split("#"); + if (!version) { + throw new WebinyError("There is no version in given ID value.", "VERSION_ERROR", { + id + }); + } - checkOwnership(form, permission, context); - - const savedOn = new Date().toISOString(); - const status = utils.getStatus({ published: true, locked: true }); - - Object.assign(form, { + let form: FbForm = undefined; + try { + form = await this.storageOperations.getForm({ + where: { + formId, + version: Number(version), published: true, - publishedOn: savedOn, - locked: true, - savedOn, - status - }); - - // Finally save it to DB - const batch = db - .batch() - .update({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_REVISION(version) - }, - data: form - }) - .update({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST_PUBLISHED() - }, - data: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST_PUBLISHED(), - TYPE: TYPE_FORM_LATEST_PUBLISHED, - id, - version: form.version - } - }); - - // Update form in "Elastic Search" - if (latestForm.id === id) { - batch.update({ - ...defaults.esDb, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST() - }, - data: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST(), - index: defaults.es(context).index, - data: getESDataForLatestRevision(form, context) - } - }); - } - - await batch.execute(); - - return form; - }, - async unpublishForm(id) { - const permission = await utils.checkBaseFormPermissions(context, { - rwd: "r", - pw: "u" - }); - - const [uniqueId, version] = id.split("#"); - const FORM_PK = PK_FORM(uniqueId); - - const [[[form]], [[latestForm]], [[latestPublishedForm]]] = await db - .batch() - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - } - }) - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - } - }) - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED() - } - }) - .execute(); - - if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); - } - - checkOwnership(form, permission, context); - - const savedOn = new Date().toISOString(); - const status = utils.getStatus({ published: false, locked: true }); - - Object.assign(form, { - published: false, - savedOn, - status - }); - - // Update DB item - const batch = db.batch().update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - }, - data: form - }); - - // Update or delete "latest published" item from DB - if (latestPublishedForm.id === id) { - const [revisions] = await db.read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: { $beginsWith: "REV#" } - }, - sort: { SK: -1 } - }); - - // Find published revision with highest publishedOn data - const publishedRevision = revisions - .filter(rev => rev.id !== id && rev.publishedOn !== null) - .sort( - (a, b) => - new Date(b.publishedOn).getTime() - - new Date(a.publishedOn).getTime() - ) - .shift(); - - if (publishedRevision) { - batch.update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED(), - TYPE: TYPE_FORM_LATEST_PUBLISHED, - id: publishedRevision.id, - version: publishedRevision.version - } - }); - } else { - batch.delete({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST_PUBLISHED() - } - }); - } + tenant: tenant.id, + locale: locale.code } - - // Update form in "Elastic Search" - if (latestForm.id === id) { - batch.update({ - ...defaults.esDb, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST() - }, - data: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_LATEST(), - index: defaults.es(context).index, - data: getESDataForLatestRevision(form, context) - } - }); + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load published form revision by ID.", + ex.code || "GET_PUBLISHED_FORM_BY_ID_ERROR", + { + id } - - await batch.execute(); - - return form; - }, - async createFormRevision(sourceRevisionId) { - await utils.checkBaseFormPermissions(context, { rwd: "w" }); - - const batch = db.batch(); - - const [uniqueId, version] = sourceRevisionId.split("#"); - const FORM_PK = PK_FORM(uniqueId); - - const [[[form]], [[latestForm]]] = await batch - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_REVISION(version) - } - }) - .read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - } - }) - .execute(); - - if (!form) { - throw new NotFoundError(`Form "${sourceRevisionId}" was not found!`); + ); + } + if (!form) { + throw new NotFoundError(`Form "${id}" was not found!`); + } + return form; + }, + async getLatestPublishedFormRevision(this: FormBuilder, id) { + /** + * Make sure we have a unique form ID, and not a revision ID + */ + const [formId] = id.split("#"); + + let form: FbForm = undefined; + try { + form = await this.storageOperations.getForm({ + where: { + formId, + published: true, + tenant: tenant.id, + locale: locale.code } - - const identity = context.security.getIdentity(); - const newVersion = latestForm.version + 1; - const id = `${uniqueId}#${zeroPad(newVersion)}`; - - const newRevision: FbForm = { - id, - locale: form.locale, - savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - ownedBy: form.ownedBy, - name: form.name, - slug: form.slug, - version: newVersion, - locked: false, - published: false, - publishedOn: null, - status: utils.getStatus({ published: false, locked: false }), - fields: form.fields, - layout: form.layout, - stats: { - submissions: 0, - views: 0 - }, - settings: form.settings, - triggers: form.triggers, - tenant: form.tenant - }; - - // Store form to DB and update `latest revision` item - await db - .batch() - .create({ - ...defaults.db, - data: { - PK: FORM_PK, - SK: SK_FORM_REVISION(newVersion), - TYPE: "fb.form", - ...newRevision - } - }) - .update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - TYPE: TYPE_FORM_LATEST, - id: newRevision.id, - version: newRevision.version - } - }) - .update({ - ...defaults.esDb, - query: { - PK: FORM_PK, - SK: SK_FORM_LATEST() - }, - data: { - PK: FORM_PK, - SK: SK_FORM_LATEST(), - index: defaults.es(context).index, - data: getESDataForLatestRevision(newRevision, context) - } - }) - .execute(); - - return newRevision; - }, - async incrementFormViews(id) { - const [uniqueId, version] = id.split("#"); - const FORM_PK = PK_FORM(uniqueId); - const FORM_SK = SK_FORM_REVISION(version); - - const [[form]] = await db.read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: FORM_SK - } - }); - - if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load published form revision by ID.", + ex.code || "GET_PUBLISHED_FORM_BY_ID_ERROR", + { + id } - - // Increment views - form.stats.views = form.stats.views + 1; - - // Update "form stats" in DB. - await db.update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: FORM_SK - }, - data: { - stats: form.stats - } - }); - - return true; + ); + } + if (!form) { + throw new NotFoundError(`Form "${id}" was not found!`); + } + return form; + }, + async createForm(this: FormBuilder, input) { + await utils.checkBaseFormPermissions(context, { rwd: "w" }); + + const identity = context.security.getIdentity(); + const dataModel = new models.FormCreateDataModel().populate(input); + await dataModel.validate(); + + const data = await dataModel.toJSON(); + + /** + * Forms are identified by a common parent ID + Revision number + */ + const formId = mdbid(); + const version = 1; + const id = createIdentifier({ + id: formId, + version + }); + + const slug = `${slugify(data.name)}-${formId}`.toLowerCase(); + + const form: FbForm = { + id, + formId, + locale: locale.code, + tenant: tenant.id, + savedOn: new Date().toISOString(), + createdOn: new Date().toISOString(), + createdBy: { + id: identity.id, + displayName: identity.displayName, + type: identity.type }, - async incrementFormSubmissions(id) { - const [uniqueId, version] = id.split("#"); - const FORM_PK = PK_FORM(uniqueId); - const FORM_SK = SK_FORM_REVISION(version); - - const [[form]] = await db.read({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: FORM_SK - } - }); - - if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); - } - - // Increment submissions - form.stats.submissions++; - - // Update "form stats" in DB. - await db.update({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: FORM_SK - }, - data: { - stats: form.stats - } - }); - - return true; + ownedBy: { + id: identity.id, + displayName: identity.displayName, + type: identity.type }, - async getSubmissionsByIds(formId, submissionIds) { - const [uniqueId] = formId.split("#"); - const FORM_PK = PK_FORM(uniqueId); - - const batch = db.batch(); - - batch.read( - ...submissionIds.map(submissionId => ({ - ...defaults.db, - query: { - PK: FORM_PK, - SK: `FS#${submissionId}` - } - })) - ); - - const response = await batch.execute(); - - return response - .map(item => { - const [[formSubmission]] = item; - return formSubmission; - }) - .filter(Boolean); + name: data.name, + slug, + version, + locked: false, + published: false, + publishedOn: null, + status: utils.getStatus({ + published: false, + locked: false + }), + stats: { + views: 0, + submissions: 0 }, - async listFormSubmissions(formId, options = {}) { - const { submissions } = await utils.checkBaseFormPermissions(context); - - if (typeof submissions !== "undefined" && submissions !== true) { - throw new NotAuthorizedError(); - } - - /** - * Check if current identity is allowed to access this form. - */ - await this.getForm(formId); - - const { sort = { createdOn: -1 }, after = null } = options; - let { limit = 10 } = options; - - // 10000 is a hard limit of ElasticSearch for `size` parameter. - if (limit >= 10000) { - limit = 9999; - } - - const [uniqueId] = formId.split("#"); - - const filter: Record[] = [ - { term: { "__type.keyword": "fb.submission" } }, - { term: { "locale.keyword": i18nContent.locale.code } }, - // Load all form submissions no matter the revision - { term: { "form.parent.keyword": uniqueId } } - ]; - - // When ES index is shared between tenants, we need to filter records by tenant ID - const sharedIndex = process.env.ELASTICSEARCH_SHARED_INDEXES === "true"; - if (sharedIndex) { - const tenant = tenancy.getCurrentTenant(); - filter.push({ term: { "tenant.keyword": tenant.id } }); + /** + * Will be added via a "update" + */ + fields: [], + layout: [], + settings: await new models.FormSettingsModel().toJSON(), + triggers: null, + webinyVersion: context.WEBINY_VERSION + }; + + try { + return await this.storageOperations.createForm({ + input, + form + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create form.", + ex.code || "CREATE_FORM_ERROR", + { + form } + ); + } + }, + async updateForm(this: FormBuilder, id, input) { + const permission = await utils.checkBaseFormPermissions(context, { rwd: "w" }); + const updateData = new models.FormUpdateDataModel().populate(input); + await updateData.validate(); + const data = await updateData.toJSON({ onlyDirty: true }); + + const original = await this.storageOperations.getForm({ + where: { + id, + tenant: tenant.id, + locale: locale.code + } + }); - const body: Record = { - query: { - bool: { filter } - }, - size: limit + 1, - sort: [{ createdOn: { order: sort.createdOn > 0 ? "asc" : "desc" } }] - }; + if (!original) { + throw new NotFoundError(`Form "${id}" was not found!`); + } - if (after) { - body["search_after"] = utils.decodeCursor(after); + checkOwnership(original, permission, context); + + const form: FbForm = { + ...original, + ...data, + savedOn: new Date().toISOString(), + tenant: tenant.id, + webinyVersion: context.WEBINY_VERSION + }; + + try { + return await this.storageOperations.updateForm({ + input: data, + form, + original + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form.", + ex.code || "UPDATE_FORM_ERROR", + { + input: data, + form, + original } + ); + } + }, + async deleteForm(this: FormBuilder, id) { + const permission = await utils.checkBaseFormPermissions(context, { rwd: "d" }); + + const form = await this.storageOperations.getForm({ + where: { + id, + tenant: tenant.id, + locale: locale.code + } + }); - const response = await elasticsearch.search({ - ...defaults.es(context), - body - }); - - const { hits, total } = response.body.hits; - const items = hits.map(item => item._source); + if (!form) { + throw new NotFoundError(`Form ${id} was not found!`); + } - const hasMoreItems = items.length > limit; - if (hasMoreItems) { - // Remove the last item from results, we don't want to include it. - items.pop(); + checkOwnership(form, permission, context); + + try { + await this.storageOperations.deleteForm({ + form + }); + return true; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form.", + ex.code || "DELETE_FORM_ERROR", + { + form } - - // Cursor is the `sort` value of the last item in the array. - // https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#search-after - - const meta = { - hasMoreItems, - totalCount: total.value, - cursor: items.length > 0 ? encodeCursor(hits[items.length - 1].sort) : null - }; - - return [items, meta]; + ); + } + }, + async deleteFormRevision(this: FormBuilder, id) { + const permission = await utils.checkBaseFormPermissions(context, { rwd: "d" }); + + const form = await this.getForm(id, { + auth: false + }); + checkOwnership(form, permission, context); + + const revisions = await this.storageOperations.listFormRevisions({ + where: { + formId: form.formId, + tenant: form.tenant, + locale: form.locale }, - async createFormSubmission(formId, reCaptchaResponseToken, rawData, meta) { - const { formBuilder } = context; - - const [uniqueId, version] = formId.split("#"); - - const [[form]] = await db.read({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_FORM_REVISION(version) - } - }); - - if (!form) { - throw new NotFoundError(`Form "${formId}" was not found!`); - } - - const settings = await formBuilder.settings.getSettings({ auth: false }); - - if (settings.reCaptcha && settings.reCaptcha.enabled) { - if (!reCaptchaResponseToken) { - throw new Error("Missing reCAPTCHA response token - cannot verify."); - } - - const { secretKey } = settings.reCaptcha; - - const recaptchaResponse = await fetch( - "https://www.google.com/recaptcha/api/siteverify", - { - method: "POST", - body: JSON.stringify({ - secret: secretKey, - response: reCaptchaResponseToken - }) - } - ); - - let responseIsValid = false; - try { - const validationResponse = await recaptchaResponse.json(); - if (validationResponse.success) { - responseIsValid = true; - } - } catch (e) {} - - if (!responseIsValid) { - throw new Error("reCAPTCHA verification failed."); - } - } - - // Validate data - const validatorPlugins = context.plugins.byType("fb-form-field-validator"); - const { fields } = form; - - const data = pick( - rawData, - fields.map(field => field.fieldId) - ); + sort: ["version_DESC"] + }); + + const previous = revisions.find(rev => rev.version < form.version); + if (!previous && revisions.length === 1) { + /** + * Means we're deleting the last revision, so we need to delete the whole form. + */ + return this.deleteForm(form.formId); + } - if (Object.keys(data).length === 0) { - throw new Error("Form data cannot be empty."); + try { + await this.storageOperations.deleteFormRevision({ + form, + previous, + revisions + }); + return true; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form revision.", + ex.code || "DELETE_FORM_REVISION_ERROR", + { + ...(ex.data || {}), + form } - - const invalidFields = {}; - for (let i = 0; i < fields.length; i++) { - const field = fields[i]; - if (Array.isArray(field.validation)) { - for (let j = 0; j < field.validation.length; j++) { - const validator = field.validation[j]; - const validatorPlugin = validatorPlugins.find( - item => item.validator.name === validator.name - ); - - if (!validatorPlugin) { - continue; - } - - let isInvalid = true; - try { - const result = await validatorPlugin.validator.validate( - data[field.fieldId], - validator - ); - isInvalid = result === false; - } catch (e) { - isInvalid = true; - } - - if (isInvalid) { - invalidFields[field.fieldId] = - validator.message || "Invalid value"; - } - } - } + ); + } + }, + async publishForm(this: FormBuilder, id) { + const permission = await utils.checkBaseFormPermissions(context, { + rwd: "r", + pw: "p" + }); + /** + * getForm checks for existence of the form. + */ + const original = await this.getForm(id, { + auth: false + }); + checkOwnership(original, permission, context); + + const form: FbForm = { + ...original, + published: true, + publishedOn: new Date().toISOString(), + locked: true, + savedOn: new Date().toISOString(), + status: utils.getStatus({ published: true, locked: true }), + tenant: tenant.id, + webinyVersion: context.WEBINY_VERSION + }; + + try { + return await this.storageOperations.publishForm({ + original, + form + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not publish form.", + ex.code || "PUBLISH_FORM_ERROR", + { + ...(ex.data || {}), + original, + form } - - if (Object.keys(invalidFields).length > 0) { - throw { - message: "Form submission contains invalid fields.", - data: { invalidFields } - }; + ); + } + }, + async unpublishForm(this: FormBuilder, id) { + const permission = await utils.checkBaseFormPermissions(context, { + rwd: "r", + pw: "u" + }); + + const original = await this.getForm(id, { + auth: false + }); + + checkOwnership(original, permission, context); + + const form: FbForm = { + ...original, + published: false, + savedOn: new Date().toISOString(), + status: utils.getStatus({ published: false, locked: true }), + tenant: tenant.id, + webinyVersion: context.WEBINY_VERSION + }; + + try { + return await this.storageOperations.unpublishForm({ + original, + form + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not unpublish form.", + ex.code || "UNPUBLISH_FORM_ERROR", + { + ...(ex.data || {}), + original, + form } - - // Use model for data validation and default values. - const submissionModel = new models.FormSubmissionCreateDataModel().populate({ - data, - meta, - form: { - id: form.id, - parent: uniqueId, - name: form.name, - version: form.version, - fields: form.fields, - layout: form.layout - } - }); - - await submissionModel.validate(); - - const submission: FbSubmission = { - id: mdbid(), - locale: form.locale, - ownedBy: form.ownedBy, - ...(await submissionModel.toJSON()) - }; - - // Store submission to DB - await db - .batch() - .create({ - ...defaults.db, - data: { - PK: PK_FORM(uniqueId), - SK: SK_SUBMISSION(submission.id), - TYPE: TYPE_FORM_SUBMISSION, - tenant: form.tenant, - ...submission - } - }) - .create({ - ...defaults.esDb, - data: { - PK: PK_FORM(uniqueId), - SK: SK_SUBMISSION(submission.id), - index: defaults.es(context).index, - data: { - __type: "fb.submission", - webinyVersion: context.WEBINY_VERSION, - createdOn: new Date().toISOString(), - tenant: context.tenancy.getCurrentTenant().id, - ...submission - } - } - }) - .execute(); - - submission.logs = [ - ...(submission.logs || []), - { - type: "info", - message: "Form submission created." - } - ]; - - try { - // Execute triggers - if (form.triggers) { - const plugins = context.plugins.byType("form-trigger-handler"); - for (let i = 0; i < plugins.length; i++) { - const plugin = plugins[i]; - if (form.triggers[plugin.trigger]) { - await plugin.handle({ - form: form, - addLog: log => { - submission.logs = [...submission.logs, log]; - }, - data, - meta, - trigger: form.triggers[plugin.trigger] - }); - } - } - } - - submission.logs = [ - ...submission.logs, - { - type: "success", - message: "Form submitted successfully." - } - ]; - - await formBuilder.forms.incrementFormSubmissions(form.id); - } catch (e) { - submission.logs = [ - ...submission.logs, - { - type: "error", - message: e.message - } - ]; - } finally { - // Save submission to include the logs that were added during trigger processing. - await formBuilder.forms.updateSubmission(form.id, submission); + ); + } + }, + async createFormRevision(this: FormBuilder, id) { + await utils.checkBaseFormPermissions(context, { rwd: "w" }); + + const original = await this.getForm(id, { + auth: false + }); + + const latest = await this.storageOperations.getForm({ + where: { + formId: original.formId, + latest: true, + tenant: original.tenant, + locale: original.locale + } + }); + + const identity = context.security.getIdentity(); + const version = (latest ? latest.version : original.version) + 1; + + const form: FbForm = { + ...original, + id: createIdentifier({ + id: original.formId, + version + }), + version, + stats: { + submissions: 0, + views: 0 + }, + savedOn: new Date().toISOString(), + createdOn: new Date().toISOString(), + createdBy: { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }, + locked: false, + published: false, + publishedOn: null, + status: utils.getStatus({ published: false, locked: false }), + tenant: tenant.id, + webinyVersion: context.WEBINY_VERSION + }; + + try { + return this.storageOperations.createFormFrom({ + original, + latest, + form + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create form from given one.", + ex.code || "CREATE_FORM_FROM_ERROR", + { + ...(ex.data || {}), + original, + form } - - return submission; + ); + } + }, + async incrementFormViews(this: FormBuilder, id) { + const original = await this.getForm(id, { + auth: false + }); + + const form: FbForm = { + ...original, + stats: { + ...original.stats, + views: original.stats.views + 1 }, - async updateSubmission(formId, data) { - await new models.FormSubmissionUpdateDataModel().populate(data).validate(); - - const [uniqueId] = formId.split("#"); - - // Finally save it to DB - await db.update({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_SUBMISSION(data.id) - }, - data: { - logs: data.logs - } - }); + tenant: tenant.id, + webinyVersion: context.WEBINY_VERSION + }; + + try { + await this.storageOperations.updateForm({ + original, + form + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form stats views stats.", + ex.code || "UPDATE_FORM_STATS_VIEWS_ERROR", + { + original, + form + } + ); + } - return true; + return true; + }, + async incrementFormSubmissions(this: FormBuilder, id) { + const original = await this.getForm(id, { + auth: false + }); + + const form: FbForm = { + ...original, + stats: { + ...original.stats, + submissions: original.stats.submissions + 1 }, - async deleteSubmission(formId, submissionId) { - const [uniqueId] = formId.split("#"); - await db - .batch() - .delete({ - ...defaults.db, - query: { - PK: PK_FORM(uniqueId), - SK: SK_SUBMISSION(submissionId) - } - }) - .delete({ - ...defaults.esDb, - query: { - PK: PK_FORM(uniqueId), - SK: SK_SUBMISSION(submissionId) - } - }) - .execute(); - - return true; - } + tenant: tenant.id, + webinyVersion: context.WEBINY_VERSION + }; + + try { + await this.storageOperations.updateForm({ + original, + form + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form stats submissions stats.", + ex.code || "UPDATE_FORM_STATS_SUBMISSIONS_ERROR", + { + original, + form + } + ); } - }; - } -} as ContextPlugin; + + return true; + } + }; +}; diff --git a/packages/api-form-builder/src/plugins/crud/forms.models.ts b/packages/api-form-builder/src/plugins/crud/forms.models.ts index fa8e3cd2dd4..8c9cb74a55c 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.models.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.models.ts @@ -1,4 +1,3 @@ -// "Form Fields" data model. import { validation } from "@webiny/validation"; import { boolean, fields, string, withFields, number } from "@commodo/fields"; import { object } from "commodo-fields-object"; @@ -8,7 +7,9 @@ export const FormFieldsModel = withFields({ type: string({ validation: validation.create("required") }), name: string({ validation: validation.create("required") }), fieldId: string({ validation: validation.create("required") }), - // Note: We've replaced "i18nString()" with "string()" + /** + * Note: We've replaced "i18nString()" with "string()" + */ label: string({ validation: validation.create("maxLength:100") }), helpText: string({ validation: validation.create("maxLength:100") }), placeholderText: string({ validation: validation.create("maxLength:100") }), @@ -32,7 +33,6 @@ export const FormFieldsModel = withFields({ settings: object({ value: {} }) })(); -// "Form Settings" data model. export const FormSettingsModel = withFields({ layout: fields({ value: {}, @@ -40,9 +40,13 @@ export const FormSettingsModel = withFields({ renderer: string({ value: "default" }) })() }), - // Note: We've replaced "i18nString()" with "string()" + /** + * Note: We've replaced "i18nString()" with "string()" + */ submitButtonLabel: string({ validation: validation.create("maxLength:100") }), - // Note: We've replaced "i18nObject()" with "object()" + /** + * Note: We've replaced "i18nObject()" with "object()" + */ successMessage: object(), termsOfServiceMessage: fields({ instanceOf: withFields({ @@ -55,7 +59,9 @@ export const FormSettingsModel = withFields({ value: {}, instanceOf: withFields({ enabled: boolean(), - // Note: We've replaced "i18nString()" with "string()" + /** + * Note: We've replaced "i18nString()" with "string()" + */ errorMessage: string({ value: "Please verify that you are not a robot.", validation: validation.create("maxLength:100") @@ -109,6 +115,7 @@ export const FormSubmissionCreateDataModel = withFields({ })(); export const FormSubmissionUpdateDataModel = withFields({ + id: string({ validation: validation.create("required,maxLength:100") }), logs: fields({ list: true, value: [], diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts new file mode 100644 index 00000000000..f62339b09d3 --- /dev/null +++ b/packages/api-form-builder/src/plugins/crud/index.ts @@ -0,0 +1,61 @@ +import { FormBuilderContext, FormBuilderStorageOperations } from "~/types"; +import { ContextPlugin } from "@webiny/handler/plugins/ContextPlugin"; +import { createSystemCrud } from "~/plugins/crud/system.crud"; +import { createSettingsCrud } from "~/plugins/crud/settings.crud"; +import { createFormsCrud } from "~/plugins/crud/forms.crud"; +import { createSubmissionsCrud } from "~/plugins/crud/submissions.crud"; +import WebinyError from "@webiny/error"; + +export interface Params { + storageOperations: FormBuilderStorageOperations; +} + +export default (params: Params) => { + const { storageOperations } = params; + + return new ContextPlugin(async context => { + const tenant = context.tenancy.getCurrentTenant(); + const identity = context.security.getIdentity(); + const locale = context.i18nContent.getLocale(); + + context.formBuilder = { + storageOperations, + ...createSystemCrud({ + identity, + tenant, + context + }), + ...createSettingsCrud({ + tenant, + locale, + context + }), + ...createFormsCrud({ + tenant, + locale, + context + }), + ...createSubmissionsCrud({ + context + }) + }; + /** + * Initialization of the storage operations. + * Used to attach subscription to form builder topics. + */ + if (!context.formBuilder.storageOperations.init) { + return; + } + try { + await context.formBuilder.storageOperations.init(context.formBuilder); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not initialize Form Builder storage operations.", + ex.code || "STORAGE_OPERATIONS_INIT_ERROR", + { + ...ex + } + ); + } + }); +}; diff --git a/packages/api-form-builder/src/plugins/crud/settings.crud.ts b/packages/api-form-builder/src/plugins/crud/settings.crud.ts index 6dee538ecad..2d370ef5a0b 100644 --- a/packages/api-form-builder/src/plugins/crud/settings.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/settings.crud.ts @@ -1,68 +1,142 @@ -import { ContextPlugin } from "@webiny/handler/types"; import * as utils from "./utils"; import * as models from "./settings.models"; -import defaults from "./defaults"; -import { Settings, FormBuilderContext } from "../../types"; +import { Settings, FormBuilderContext, SettingsCRUD, FormBuilder } from "~/types"; +import WebinyError from "@webiny/error"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { I18NLocale } from "@webiny/api-i18n/types"; +import { NotFoundError } from "@webiny/handler-graphql"; -export const FB_SETTINGS_KEY = "default"; +export interface Params { + tenant: Tenant; + locale: I18NLocale; + context: FormBuilderContext; +} -export default { - type: "context", - apply(context) { - const { db } = context; +export const createSettingsCrud = (params: Params): SettingsCRUD => { + const { tenant, locale, context } = params; - const PK_SETTINGS = () => `${utils.getPKPrefix(context)}SETTINGS`; + return { + async getSettings(this: FormBuilder, params) { + const { auth, throwOnNotFound } = params || {}; - context.formBuilder = { - ...context.formBuilder, - settings: { - async getSettings(options = { auth: true }) { - if (options.auth) { - await utils.checkBaseSettingsPermissions(context); - } + if (auth !== false) { + await utils.checkBaseSettingsPermissions(context); + } - const [[settings]] = await db.read({ - ...defaults.db, - query: { PK: PK_SETTINGS(), SK: FB_SETTINGS_KEY } - }); + let settings: Settings = null; + try { + settings = await this.storageOperations.getSettings({ + tenant: tenant.id, + locale: locale.code + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load settings.", + ex.code || "GET_SETTINGS_ERROR" + ); + } + if (throwOnNotFound === true && !settings) { + throw new NotFoundError(`"Form Builder" settings not found!`); + } + return settings; + }, + async createSettings(this: FormBuilder, input) { + const formBuilderSettings = new models.CreateDataModel().populate(input); + await formBuilderSettings.validate(); - return settings; - }, - async createSettings(data) { - const formBuilderSettings = new models.CreateDataModel().populate(data); - await formBuilderSettings.validate(); + const data = await formBuilderSettings.toJSON(); - const dataJSON = await formBuilderSettings.toJSON(); + const original = await this.getSettings({ auth: false }); + if (original) { + throw new WebinyError( + `"Form Builder" settings already exist.`, + "FORM_BUILDER_SETTINGS_CREATE_ERROR", + { + settings: original + } + ); + } + /** + * Assign specific properties, just to be sure nothing else gets in the record. + */ + const settings: Settings = { + domain: data.domain, + reCaptcha: data.reCaptcha, + tenant: tenant.id, + locale: locale.code + }; + try { + return await this.storageOperations.createSettings({ + settings + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create settings.", + ex.code || "CREATE_SETTINGS_ERROR", + { + settings, + input + } + ); + } + }, + async updateSettings(this: FormBuilder, data) { + await utils.checkBaseSettingsPermissions(context); + const updatedData = new models.UpdateDataModel().populate(data); + await updatedData.validate(); - await db.create({ - data: { - PK: PK_SETTINGS(), - SK: FB_SETTINGS_KEY, - TYPE: "fb.settings", - ...dataJSON - } - }); + const newSettings = await updatedData.toJSON({ onlyDirty: true }); + const original = await this.getSettings(); + if (!original) { + throw new NotFoundError(`"Form Builder" settings not found!`); + } - return dataJSON; + /** + * Assign specific properties, just to be sure nothing else gets in the record. + */ + const settings: Settings = Object.keys(newSettings).reduce( + (collection, key) => { + if (newSettings[key] === undefined) { + return collection; + } + collection[key] = newSettings[key]; + return collection; }, - async updateSettings(data) { - await utils.checkBaseSettingsPermissions(context); - const updatedData = new models.UpdateDataModel().populate(data); - await updatedData.validate(); - - const newSettings = await updatedData.toJSON({ onlyDirty: true }); - - const settings = await this.getSettings(); + { + ...original, + tenant: tenant.id, + locale: locale.code + } + ); + try { + return await this.storageOperations.updateSettings({ + settings, + original + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update settings.", + ex.code || "UPDATE_SETTINGS_ERROR", + { + settings, + original + } + ); + } + }, - await db.update({ - ...defaults.db, - query: { PK: PK_SETTINGS(), SK: FB_SETTINGS_KEY }, - data: newSettings - }); + async deleteSettings(this: FormBuilder) { + await utils.checkBaseSettingsPermissions(context); + const settings = await this.getSettings(); - return { ...settings, ...newSettings }; - } + try { + await this.storageOperations.deleteSettings({ settings }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete settings.", + ex.code || "DELETE_SETTINGS_ERROR" + ); } - }; - } -} as ContextPlugin; + } + }; +}; diff --git a/packages/api-form-builder/src/plugins/crud/settings.models.ts b/packages/api-form-builder/src/plugins/crud/settings.models.ts index 561e85781f9..e5de5de9122 100644 --- a/packages/api-form-builder/src/plugins/crud/settings.models.ts +++ b/packages/api-form-builder/src/plugins/crud/settings.models.ts @@ -2,7 +2,6 @@ import { validation } from "@webiny/validation"; import { withFields, string, boolean, fields } from "@commodo/fields"; export const CreateDataModel = withFields({ - installed: boolean({ value: false }), domain: string(), reCaptcha: fields({ value: {}, diff --git a/packages/api-form-builder/src/plugins/crud/submissions.crud.ts b/packages/api-form-builder/src/plugins/crud/submissions.crud.ts new file mode 100644 index 00000000000..487312166b0 --- /dev/null +++ b/packages/api-form-builder/src/plugins/crud/submissions.crud.ts @@ -0,0 +1,380 @@ +import mdbid from "mdbid"; +import fetch from "node-fetch"; +import pick from "lodash/pick"; +import WebinyError from "@webiny/error"; +import * as utils from "~/plugins/crud/utils"; +import * as models from "~/plugins/crud/forms.models"; +import { + FbForm, + FbSubmission, + FormBuilder, + FormBuilderContext, + FormBuilderStorageOperationsListSubmissionsParams, + SubmissionsCRUD +} from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { NotAuthorizedError } from "@webiny/api-security"; + +export interface Params { + context: FormBuilderContext; +} + +export const createSubmissionsCrud = (params: Params): SubmissionsCRUD => { + const { context } = params; + + return { + async getSubmissionsByIds(this: FormBuilder, formId, submissionIds) { + let form: FbForm; + if (typeof formId === "string") { + form = await this.getForm(formId, { + auth: false + }); + if (!form) { + throw new NotFoundError("Form not found"); + } + } else { + form = formId; + } + + const listSubmissionsParams: FormBuilderStorageOperationsListSubmissionsParams = { + where: { + id_in: submissionIds, + parent: form.formId, + tenant: form.tenant, + locale: form.locale + } + }; + + try { + const { items } = await this.storageOperations.listSubmissions( + listSubmissionsParams + ); + + return items; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list all form submissions.", + ex.code || "LIST_FORM_SUBMISSIONS_ERROR", + { + params: listSubmissionsParams + } + ); + } + }, + async listFormSubmissions(this: FormBuilder, formId, options = {}) { + const { submissions } = await utils.checkBaseFormPermissions(context); + + if (typeof submissions !== "undefined" && submissions !== true) { + throw new NotAuthorizedError(); + } + + /** + * Check if current identity is allowed to access this form. + */ + const form = await this.getForm(formId); + + const { sort: initialSort, after = null, limit = 10 } = options; + + const listSubmissionsParams: FormBuilderStorageOperationsListSubmissionsParams = { + where: { + tenant: form.tenant, + locale: form.locale, + parent: form.formId + }, + after, + limit, + sort: + Array.isArray(initialSort) && initialSort.length + ? initialSort + : ["createdOn_DESC"] + }; + + try { + const result = await this.storageOperations.listSubmissions(listSubmissionsParams); + + return [result.items, result.meta]; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list form submissions.", + ex.code || "LIST_FORM_SUBMISSIONS_ERROR", + { + params: listSubmissionsParams + } + ); + } + }, + async createFormSubmission( + this: FormBuilder, + formId, + reCaptchaResponseToken, + rawData, + meta + ) { + const form = await this.getForm(formId, { + auth: false + }); + + const settings = await this.getSettings({ + auth: false, + throwOnNotFound: true + }); + + if (settings.reCaptcha && settings.reCaptcha.enabled) { + if (!reCaptchaResponseToken) { + throw new Error("Missing reCAPTCHA response token - cannot verify."); + } + + const { secretKey } = settings.reCaptcha; + + const recaptchaResponse = await fetch( + "https://www.google.com/recaptcha/api/siteverify", + { + method: "POST", + body: JSON.stringify({ + secret: secretKey, + response: reCaptchaResponseToken + }) + } + ); + + let responseIsValid = false; + try { + const validationResponse = await recaptchaResponse.json(); + if (validationResponse.success) { + responseIsValid = true; + } + } catch (e) {} + + if (!responseIsValid) { + throw new Error("reCAPTCHA verification failed."); + } + } + + /** + * Validate data + */ + const validatorPlugins = context.plugins.byType("fb-form-field-validator"); + const { fields } = form; + + const data = pick( + rawData, + fields.map(field => field.fieldId) + ); + + if (Object.keys(data).length === 0) { + throw new Error("Form data cannot be empty."); + } + + const invalidFields = {}; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + if (Array.isArray(field.validation)) { + for (let j = 0; j < field.validation.length; j++) { + const validator = field.validation[j]; + const validatorPlugin = validatorPlugins.find( + item => item.validator.name === validator.name + ); + + if (!validatorPlugin) { + continue; + } + + let isInvalid = true; + try { + const result = await validatorPlugin.validator.validate( + data[field.fieldId], + validator + ); + isInvalid = result === false; + } catch (e) { + isInvalid = true; + } + + if (isInvalid) { + invalidFields[field.fieldId] = validator.message || "Invalid value"; + } + } + } + } + + if (Object.keys(invalidFields).length > 0) { + throw { + message: "Form submission contains invalid fields.", + data: { invalidFields } + }; + } + + /** + * Use model for data validation and default values. + */ + const submissionModel = new models.FormSubmissionCreateDataModel().populate({ + data, + meta, + form: { + id: form.id, + parent: form.formId, + name: form.name, + version: form.version, + fields: form.fields, + layout: form.layout + } + }); + + await submissionModel.validate(); + + const modelData: Pick = + await submissionModel.toJSON(); + + const submission: FbSubmission = { + ...modelData, + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + id: mdbid(), + locale: form.locale, + ownedBy: form.ownedBy, + tenant: form.tenant, + logs: [], + webinyVersion: context.WEBINY_VERSION + }; + + try { + await this.storageOperations.createSubmission({ + input: modelData, + form, + submission + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create form submission.", + ex.code || "CREATE_FORM_SUBMISSION_ERROR", + { + ...(ex.data || {}), + input: modelData, + form, + submission + } + ); + } + + submission.logs.push({ + type: "info", + message: "Form submission created." + }); + + try { + /** + * Execute triggers + */ + if (form.triggers) { + const plugins = context.plugins.byType("form-trigger-handler"); + for (let i = 0; i < plugins.length; i++) { + const plugin = plugins[i]; + if (form.triggers[plugin.trigger]) { + await plugin.handle({ + form: form, + addLog: log => { + submission.logs.push(log); + }, + data, + meta, + trigger: form.triggers[plugin.trigger] + }); + } + } + } + + submission.logs.push({ + type: "success", + message: "Form submitted successfully." + }); + + await this.incrementFormSubmissions(form.id); + } catch (e) { + submission.logs.push({ + type: "error", + message: e.message + }); + } finally { + /** + * Save submission to include the logs that were added during trigger processing. + */ + await this.updateSubmission(form.id, submission); + } + + return submission; + }, + async updateSubmission(this: FormBuilder, formId, input) { + const data = await new models.FormSubmissionUpdateDataModel().populate(input); + data.validate(); + + const updatedData = data.toJSON(); + + const submissionId = input.id; + + const form = await this.getForm(formId, { + auth: false + }); + + const [original] = await this.getSubmissionsByIds(formId, [submissionId]); + if (!original) { + throw new NotFoundError("Submission not found."); + } + + /** + * We only want to update the logs. Just in case something else slips through the input. + */ + const submission: FbSubmission = { + ...original, + tenant: form.tenant, + logs: updatedData.logs, + webinyVersion: context.WEBINY_VERSION + }; + + try { + await this.storageOperations.updateSubmission({ + input: updatedData, + form, + original, + submission + }); + return true; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form submission.", + ex.code || "UPDATE_SUBMISSION_ERROR", + { + input: updatedData, + original, + submission, + form: formId + } + ); + } + }, + async deleteSubmission(this: FormBuilder, formId, submissionId) { + const form = await this.getForm(formId); + + const [submission] = await this.getSubmissionsByIds(form, [submissionId]); + if (!submission) { + throw new NotFoundError("Submission not found."); + } + try { + await this.storageOperations.deleteSubmission({ + form, + submission + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form submission.", + ex.code || "DELETE_SUBMISSION_ERROR", + { + submission: submissionId, + form: formId + } + ); + } + + return true; + } + }; +}; diff --git a/packages/api-form-builder/src/plugins/crud/system.crud.ts b/packages/api-form-builder/src/plugins/crud/system.crud.ts index 8a7713fac41..78783fd11b6 100644 --- a/packages/api-form-builder/src/plugins/crud/system.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/system.crud.ts @@ -1,157 +1,161 @@ -import Error from "@webiny/error"; -import { NotAuthorizedError } from "@webiny/api-security"; +import WebinyError from "@webiny/error"; +import { NotAuthorizedError, SecurityIdentity } from "@webiny/api-security"; import { UpgradePlugin } from "@webiny/api-upgrade/types"; import { getApplicablePlugin } from "@webiny/api-upgrade"; -import defaults from "./defaults"; -import { FormBuilderContext, Settings } from "../../types"; - -export default { - type: "context", - apply(context: FormBuilderContext) { - const { tenancy } = context; - const keys = () => ({ PK: `T#${tenancy.getCurrentTenant().id}#SYSTEM`, SK: "FB" }); - - context.formBuilder = { - ...context.formBuilder, - system: { - async getVersion() { - const { db } = context; - - const [[system]] = await db.read({ - ...defaults.db, - query: keys() - }); - - return system ? system.version : null; - }, - async setVersion(version: string) { - const { db } = context; - - const [[system]] = await db.read({ - ...defaults.db, - query: keys() +import { + AfterInstallTopic, + BeforeInstallTopic, + FormBuilder, + FormBuilderContext, + Settings, + System, + SystemCRUD +} from "~/types"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { createTopic } from "@webiny/pubsub"; + +export interface Params { + identity: SecurityIdentity; + tenant: Tenant; + context: FormBuilderContext; +} + +export const createSystemCrud = (params: Params): SystemCRUD => { + const { tenant, identity, context } = params; + + const onBeforeInstall = createTopic(); + const onAfterInstall = createTopic(); + + return { + onBeforeInstall, + onAfterInstall, + async getSystem(this: FormBuilder) { + try { + return await this.storageOperations.getSystem({ + tenant: tenant.id + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load system.", + ex.code || "GET_SYSTEM_ERROR" + ); + } + }, + async getSystemVersion(this: FormBuilder) { + const system = await this.getSystem(); + return system ? system.version : null; + }, + async setSystemVersion(this: FormBuilder, version: string) { + const original = await this.getSystem(); + const system: System = { + version, + tenant: tenant.id + }; + if (!original) { + try { + await this.storageOperations.createSystem({ + system }); + return; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create system.", + ex.code || "CREATE_SYSTEM_ERROR", + { + system + } + ); + } + } - if (system) { - await db.update({ - ...defaults.db, - query: keys(), - data: { - version - } - }); - } else { - await db.create({ - ...defaults.db, - data: { - ...keys(), - version - } - }); - } - }, - async install({ domain }) { - const { db, i18n, formBuilder, elasticsearch } = context; - - const version = await this.getVersion(); - if (version) { - throw new Error( - "Form builder is already installed.", - "FORM_BUILDER_INSTALL_ABORTED" - ); + try { + await this.storageOperations.updateSystem({ + original, + system + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update system.", + ex.code || "UPDATE_SYSTEM_ERROR", + { + system, + original } + ); + } + }, + async installSystem(this: FormBuilder, { domain }) { + const version = await this.getSystemVersion(); + if (version) { + throw new WebinyError( + "Form builder is already installed.", + "FORM_BUILDER_INSTALL_ABORTED" + ); + } - // Prepare "settings" data - const data: Partial = {}; + /** + * Prepare "settings" data + */ + const data: Partial = {}; - if (domain) { - data.domain = domain; - } + if (domain) { + data.domain = domain; + } - await formBuilder.settings.createSettings(data); - - // Create ES index if it doesn't already exist. - try { - const esIndex = defaults.es(context); - const { body: exists } = await elasticsearch.indices.exists(esIndex); - if (!exists) { - await elasticsearch.indices.create({ - ...esIndex, - body: { - // need this part for sorting to work on text fields - settings: { - analysis: { - analyzer: { - lowercase_analyzer: { - type: "custom", - filter: ["lowercase", "trim"], - tokenizer: "keyword" - } - } - } - }, - mappings: { - properties: { - property: { - type: "text", - fields: { - keyword: { - type: "keyword", - ignore_above: 256 - } - }, - analyzer: "lowercase_analyzer" - } - } - } - } - }); - } - } catch (err) { - await db.delete({ - ...defaults.db, - query: { - PK: `T#root#L#${i18n.getDefaultLocale().code}#FB#SETTINGS`, - SK: "default" - } - }); - - throw new Error( - "Form builder failed to install!", - "FORM_BUILDER_INSTALL_ABORTED", - { - reason: err.message - } - ); + try { + await onBeforeInstall.publish({ + tenant + }); + + await this.createSettings(data); + + await onAfterInstall.publish({ + tenant + }); + await this.setSystemVersion(context.WEBINY_VERSION); + } catch (err) { + await this.deleteSettings(); + + throw new WebinyError( + "Form builder failed to install!", + "FORM_BUILDER_INSTALL_ABORTED", + { + reason: err.message } + ); + } + }, + async upgradeSystem(this: FormBuilder, version: string) { + if (!identity) { + throw new NotAuthorizedError(); + } - await formBuilder.system.setVersion(context.WEBINY_VERSION); - }, - async upgrade(version) { - const identity = context.security.getIdentity(); - if (!identity) { - throw new NotAuthorizedError(); - } + const upgradePlugins: UpgradePlugin[] = []; - const upgradePlugins = context.plugins - .byType("api-upgrade") - .filter(pl => pl.app === "form-builder"); + /** + * There are no more registered plugins for the upgrades because each storage operations gives it's own, if some upgrade exists. + */ + if (this.storageOperations.upgrade) { + upgradePlugins.push(this.storageOperations.upgrade); + } - const plugin = getApplicablePlugin({ - deployedVersion: context.WEBINY_VERSION, - installedAppVersion: await this.getVersion(), - upgradePlugins, - upgradeToVersion: version - }); + const installedAppVersion = await this.getSystemVersion(); - await plugin.apply(context); + const plugin = getApplicablePlugin({ + deployedVersion: context.WEBINY_VERSION, + installedAppVersion, + upgradePlugins, + upgradeToVersion: version + }); - // Store new app version - await this.setVersion(version); + await plugin.apply(context); - return true; - } - } - }; - } + /** + * Store new app version + */ + await this.setSystemVersion(version); + + return true; + } + }; }; diff --git a/packages/api-form-builder/src/plugins/crud/utils.ts b/packages/api-form-builder/src/plugins/crud/utils.ts index c86cdb478b5..a55f5d01f78 100644 --- a/packages/api-form-builder/src/plugins/crud/utils.ts +++ b/packages/api-form-builder/src/plugins/crud/utils.ts @@ -1,10 +1,5 @@ import { NotAuthorizedError } from "@webiny/api-security"; -import { - FbForm, - FbFormPermission, - FbFormSettingsPermission, - FormBuilderContext -} from "../../types"; +import { FbForm, FbFormPermission, FbFormSettingsPermission, FormBuilderContext } from "~/types"; export const checkBaseFormPermissions = async ( context: FormBuilderContext, @@ -40,13 +35,6 @@ export const checkBaseSettingsPermissions = async ( return permission; }; -export const getFormId = (form: FbForm) => { - if (form.id.includes("#")) { - return `${form.id.split("#")[0]}#${form.version}`; - } - return `${form.id}#${form.version}`; -}; - export const getStatus = (params: { published: boolean; locked: boolean }) => { if (params.published) { return "published"; @@ -55,7 +43,9 @@ export const getStatus = (params: { published: boolean; locked: boolean }) => { return params.locked ? "locked" : "draft"; }; -// Has read/write/delete permissions? +/** + * Has read/write/delete permissions? + */ export const hasRwd = (permission: FbFormPermission, rwd: string) => { if (typeof permission.rwd !== "string") { return true; @@ -64,12 +54,19 @@ export const hasRwd = (permission: FbFormPermission, rwd: string) => { return permission.rwd.includes(rwd); }; -// Has publishing workflow permissions? +/** + * Has publishing workflow permissions? + */ export const hasPW = (permission: FbFormPermission, pw: string) => { - const isCustom = Object.keys(permission).length > 1; // "name" key is always present + /** + * "name" key is always present + */ + const isCustom = Object.keys(permission).length > 1; if (!isCustom) { - // Means it's a "full-access" permission. + /** + * Means it's a "full-access" permission. + */ return true; } @@ -80,39 +77,6 @@ export const hasPW = (permission: FbFormPermission, pw: string) => { return permission.pw.includes(pw); }; -export const normalizeSortInput = (sort: Record) => { - const [[key, value]] = Object.entries(sort); - - const shouldUseKeyword = ["name"]; - - if (shouldUseKeyword.includes(key)) { - return { - [`${key}.keyword`]: { - order: value === -1 ? "desc" : "asc" - } - }; - } - - return { - [key]: { - order: value === -1 ? "desc" : "asc" - } - }; -}; - -export const getPKPrefix = (context: FormBuilderContext) => { - const { tenancy, i18nContent } = context; - if (!tenancy.getCurrentTenant()) { - throw new Error("Tenant missing."); - } - - if (!i18nContent.getLocale()) { - throw new Error("Locale missing."); - } - - return `T#${tenancy.getCurrentTenant().id}#L#${i18nContent.getLocale().code}#FB#`; -}; - export const checkOwnership = ( form: FbForm, permission: FbFormPermission, @@ -125,30 +89,3 @@ export const checkOwnership = ( } } }; - -export const encodeCursor = cursor => { - if (!cursor) { - return null; - } - - return Buffer.from(JSON.stringify(cursor)).toString("base64"); -}; - -export const decodeCursor = cursor => { - if (!cursor) { - return null; - } - - return JSON.parse(Buffer.from(cursor, "base64").toString("ascii")); -}; - -export const paginateBatch = async >( - items: T[], - perPage: number, - execute: (items: T[]) => Promise -) => { - const pages = Math.ceil(items.length / perPage); - for (let i = 0; i < pages; i++) { - await execute(items.slice(i * perPage, i * perPage + perPage)); - } -}; diff --git a/packages/api-form-builder/src/plugins/graphql.ts b/packages/api-form-builder/src/plugins/graphql.ts index 4b1517f4a47..dcc9b0f44ed 100644 --- a/packages/api-form-builder/src/plugins/graphql.ts +++ b/packages/api-form-builder/src/plugins/graphql.ts @@ -1,6 +1,6 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; import { ErrorResponse, Response } from "@webiny/handler-graphql"; -import { FormBuilderContext } from "../types"; +import { FormBuilderContext } from "~/types"; const emptyResolver = () => ({}); @@ -62,7 +62,7 @@ const plugin: GraphQLSchemaPlugin = { } try { - return formBuilder.system.getVersion(); + return formBuilder.getSystemVersion(); } catch (e) { return new ErrorResponse({ code: "FORM_BUILDER_ERROR", @@ -75,7 +75,7 @@ const plugin: GraphQLSchemaPlugin = { FbMutation: { install: async (root, args, context) => { try { - await context.formBuilder.system.install({ domain: args.domain }); + await context.formBuilder.installSystem({ domain: args.domain }); return new Response(true); } catch (e) { @@ -88,7 +88,7 @@ const plugin: GraphQLSchemaPlugin = { }, upgrade: async (root, args, context) => { try { - await context.formBuilder.system.upgrade(args.version as string); + await context.formBuilder.upgradeSystem(args.version as string); return new Response(true); } catch (e) { diff --git a/packages/api-form-builder/src/plugins/graphql/form.ts b/packages/api-form-builder/src/plugins/graphql/form.ts index 13ceb98c26d..673fef2a5e4 100644 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ b/packages/api-form-builder/src/plugins/graphql/form.ts @@ -6,7 +6,7 @@ import { Response } from "@webiny/handler-graphql/responses"; import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; -import { FormBuilderContext } from "../../types"; +import { FormBuilderContext } from "~/types"; const plugin: GraphQLSchemaPlugin = { type: "graphql-schema", @@ -25,24 +25,25 @@ const plugin: GraphQLSchemaPlugin = { } type FbForm { - id: ID - createdBy: FbFormUser - ownedBy: FbFormUser - createdOn: DateTime - savedOn: DateTime + id: ID! + formId: ID! + createdBy: FbFormUser! + ownedBy: FbFormUser! + createdOn: DateTime! + savedOn: DateTime! publishedOn: DateTime - version: Int - name: String - slug: String - fields: [FbFormFieldType] - layout: [[String]] - settings: FbFormSettingsType + version: Int! + name: String! + slug: String! + fields: [FbFormFieldType!]! + layout: [[String]]! + settings: FbFormSettingsType! triggers: JSON - published: Boolean - locked: Boolean - status: FbFormStatusEnum - stats: FbFormStatsType - overallStats: FbFormStatsType + published: Boolean! + locked: Boolean! + status: FbFormStatusEnum! + stats: FbFormStatsType! + overallStats: FbFormStatsType! } type FbFieldOptionsType { @@ -243,8 +244,11 @@ const plugin: GraphQLSchemaPlugin = { error: FbError } - input FbSubmissionSortInput { - createdOn: Int + enum FbSubmissionSort { + createdOn_ASC + createdOn_DESC + savedOn_ASC + savedOn_DESC } extend type FbQuery { @@ -263,7 +267,7 @@ const plugin: GraphQLSchemaPlugin = { # List form submissions for specific Form listFormSubmissions( form: ID! - sort: FbSubmissionSortInput + sort: [FbSubmissionSort!] limit: Int after: String ): FbFormSubmissionsListResponse @@ -308,10 +312,10 @@ const plugin: GraphQLSchemaPlugin = { resolvers: { FbForm: { overallStats: (form, args, { formBuilder }) => { - return formBuilder.forms.getFormStats(form.id); + return formBuilder.getFormStats(form.id); }, settings: async (form, args, { formBuilder }) => { - const settings = await formBuilder.settings.getSettings({ auth: false }); + const settings = await formBuilder.getSettings({ auth: false }); return { ...form.settings, @@ -325,7 +329,7 @@ const plugin: GraphQLSchemaPlugin = { FbQuery: { getForm: async (_, args, { formBuilder }) => { try { - const form = await formBuilder.forms.getForm(args.revision); + const form = await formBuilder.getForm(args.revision); return new Response(form); } catch (e) { @@ -334,7 +338,7 @@ const plugin: GraphQLSchemaPlugin = { }, getFormRevisions: async (_, args, { formBuilder }) => { try { - const revisions = await formBuilder.forms.getFormRevisions(args.id); + const revisions = await formBuilder.getFormRevisions(args.id); return new Response(revisions); } catch (e) { @@ -343,7 +347,7 @@ const plugin: GraphQLSchemaPlugin = { }, listForms: async (_, args, { formBuilder }) => { try { - const forms = await formBuilder.forms.listForms(); + const forms = await formBuilder.listForms(); return new ListResponse(forms); } catch (e) { @@ -362,11 +366,15 @@ const plugin: GraphQLSchemaPlugin = { let form; if (args.revision) { - // This fetches the exact revision specified by revision ID - form = await formBuilder.forms.getPublishedFormRevisionById(args.revision); + /** + * This fetches the exact revision specified by revision ID + */ + form = await formBuilder.getPublishedFormRevisionById(args.revision); } else if (args.parent) { - // This fetches the latest published revision for given parent form - form = await formBuilder.forms.getLatestPublishedFormRevision(args.parent); + /** + * This fetches the latest published revision for given parent form + */ + form = await formBuilder.getLatestPublishedFormRevision(args.parent); } if (!form) { @@ -379,7 +387,7 @@ const plugin: GraphQLSchemaPlugin = { _, args: { form: string; - sort?: Record; + sort?: string[]; limit?: number; after?: string; }, @@ -387,7 +395,7 @@ const plugin: GraphQLSchemaPlugin = { ) => { try { const { form, ...options } = args; - const [submissions, meta] = await formBuilder.forms.listFormSubmissions( + const [submissions, meta] = await formBuilder.listFormSubmissions( form, options ); @@ -398,50 +406,60 @@ const plugin: GraphQLSchemaPlugin = { } }, FbMutation: { - // Creates a new form + /** + * Creates a new form + */ createForm: async (_, args, { formBuilder }) => { try { - const form = await formBuilder.forms.createForm(args.data); + const form = await formBuilder.createForm(args.data); return new Response(form); } catch (e) { return new ErrorResponse(e); } }, - // Deletes the entire form with all of its revisions + /** + * Deletes the entire form with all of its revisions + */ deleteForm: async (_, args, { formBuilder }) => { try { - await formBuilder.forms.deleteForm(args.id); + await formBuilder.deleteForm(args.id); return new Response(true); } catch (e) { return new ErrorResponse(e); } }, - // Creates a revision from the given revision + /** + * Creates a revision from the given revision + */ createRevisionFrom: async (_, args, { formBuilder }) => { try { - const form = await formBuilder.forms.createFormRevision(args.revision); + const form = await formBuilder.createFormRevision(args.revision); return new Response(form); } catch (e) { return new ErrorResponse(e); } }, - // Updates revision + /** + * Updates revision + */ updateRevision: async (_, args, { formBuilder }) => { try { - const form = await formBuilder.forms.updateForm(args.revision, args.data); + const form = await formBuilder.updateForm(args.revision, args.data); return new Response(form); } catch (e) { return new ErrorResponse(e); } }, - // Publish revision (must be given an exact revision ID to publish) + /** + * Publish revision (must be given an exact revision ID to publish) + */ publishRevision: async (_, { revision }, { formBuilder }) => { try { - const form = await formBuilder.forms.publishForm(revision); + const form = await formBuilder.publishForm(revision); return new Response(form); } catch (e) { @@ -450,17 +468,19 @@ const plugin: GraphQLSchemaPlugin = { }, unpublishRevision: async (_, args, { formBuilder }) => { try { - const form = await formBuilder.forms.unpublishForm(args.revision); + const form = await formBuilder.unpublishForm(args.revision); return new Response(form); } catch (e) { return new ErrorResponse(e); } }, - // Delete a revision + /** + * Delete a revision + */ deleteRevision: async (_, args, { formBuilder }) => { try { - await formBuilder.forms.deleteRevision(args.revision); + await formBuilder.deleteFormRevision(args.revision); return new Response(true); } catch (e) { @@ -469,7 +489,7 @@ const plugin: GraphQLSchemaPlugin = { }, saveFormView: async (_, args, { formBuilder }) => { try { - const form = await formBuilder.forms.incrementFormViews(args.revision); + const form = await formBuilder.incrementFormViews(args.revision); return new Response(form); } catch (e) { @@ -480,7 +500,7 @@ const plugin: GraphQLSchemaPlugin = { const { revision, data, reCaptchaResponseToken, meta = {} } = args; try { - const formSubmission = await formBuilder.forms.createFormSubmission( + const formSubmission = await formBuilder.createFormSubmission( revision, reCaptchaResponseToken, data, @@ -496,7 +516,7 @@ const plugin: GraphQLSchemaPlugin = { const { form } = args; try { - const [submissions] = await formBuilder.forms.listFormSubmissions(form, { + const [submissions] = await formBuilder.listFormSubmissions(form, { limit: 10000 }); @@ -504,14 +524,18 @@ const plugin: GraphQLSchemaPlugin = { return new NotFoundResponse("No form submissions found."); } - // Get all revisions of the form. - const revisions = await formBuilder.forms.getFormRevisions(form); + /** + * Get all revisions of the form. + */ + const revisions = await formBuilder.getFormRevisions(form); const publishedRevisions = revisions.filter(r => r.published); const rows = []; const fields = {}; - // First extract all distinct fields across all form submissions. + /** + * First extract all distinct fields across all form submissions. + */ for (let i = 0; i < publishedRevisions.length; i++) { const revision = publishedRevisions[i]; for (let j = 0; j < revision.fields.length; j++) { @@ -522,7 +546,9 @@ const plugin: GraphQLSchemaPlugin = { } } - // Build rows. + /** + * Build rows. + */ for (let i = 0; i < submissions.length; i++) { const submissionData = submissions[i].data; const row = {}; @@ -536,7 +562,9 @@ const plugin: GraphQLSchemaPlugin = { rows.push(row); } - // Save CSV file and return its URL to the client. + /** + * Save CSV file and return its URL to the client. + */ const csv = await parseAsync(rows, { fields: Object.values(fields) }); const buffer = Buffer.from(csv); const { key } = await fileManager.storage.upload({ diff --git a/packages/api-form-builder/src/plugins/graphql/formSettings.ts b/packages/api-form-builder/src/plugins/graphql/formSettings.ts index 8b4020941e1..fbb106a6d31 100644 --- a/packages/api-form-builder/src/plugins/graphql/formSettings.ts +++ b/packages/api-form-builder/src/plugins/graphql/formSettings.ts @@ -1,6 +1,6 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; -import { ErrorResponse, NotFoundResponse, Response } from "@webiny/handler-graphql/responses"; -import { FormBuilderContext } from "../../types"; +import { ErrorResponse, Response } from "@webiny/handler-graphql/responses"; +import { FormBuilderContext } from "~/types"; const plugin: GraphQLSchemaPlugin = { type: "graphql-schema", @@ -45,10 +45,9 @@ const plugin: GraphQLSchemaPlugin = { FbQuery: { async getSettings(_, args, { formBuilder }) { try { - const settings = await formBuilder.settings.getSettings(); - if (!settings) { - return new NotFoundResponse(`"Form Builder" settings not found!`); - } + const settings = await formBuilder.getSettings({ + throwOnNotFound: true + }); return new Response(settings); } catch (err) { return new ErrorResponse(err); @@ -58,13 +57,7 @@ const plugin: GraphQLSchemaPlugin = { FbMutation: { updateSettings: async (_, args, { formBuilder }) => { try { - const existingSettings = await formBuilder.settings.getSettings(); - - if (!existingSettings) { - return new NotFoundResponse(`"Form Builder" settings not found!`); - } - - const settings = await formBuilder.settings.updateSettings(args.data); + const settings = await formBuilder.updateSettings(args.data); return new Response(settings); } catch (err) { return new ErrorResponse(err); diff --git a/packages/api-form-builder/src/plugins/index.ts b/packages/api-form-builder/src/plugins/index.ts deleted file mode 100644 index 7ad104658b2..00000000000 --- a/packages/api-form-builder/src/plugins/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import forms from "./crud/forms.crud"; -import settings from "./crud/settings.crud"; -import system from "./crud/system.crud"; -import graphql from "./graphql"; -import upgrades from "./upgrades"; -import triggerHandlers from "./triggers/triggerHandlers"; -import validators from "./validators"; -import formsGraphQL from "./graphql/form"; -import formSettingsGraphQL from "./graphql/formSettings"; - -export default () => [ - forms, - settings, - system, - graphql, - upgrades, - triggerHandlers, - validators, - formsGraphQL, - formSettingsGraphQL -]; diff --git a/packages/api-form-builder/src/plugins/triggers/triggerHandlers.ts b/packages/api-form-builder/src/plugins/triggers/triggerHandlers.ts index 40738e26b26..906c40fab77 100644 --- a/packages/api-form-builder/src/plugins/triggers/triggerHandlers.ts +++ b/packages/api-form-builder/src/plugins/triggers/triggerHandlers.ts @@ -1,5 +1,5 @@ import got from "got"; -import { FbFormTriggerHandlerPlugin } from "../../types"; +import { FbFormTriggerHandlerPlugin } from "~/types"; const plugin: FbFormTriggerHandlerPlugin = { type: "form-trigger-handler", @@ -10,9 +10,11 @@ const plugin: FbFormTriggerHandlerPlugin = { if (Array.isArray(urls)) { for (let i = 0; i < urls.length; i++) { const url = urls[i]; - // Could be executed without awaiting the end result of the trigger? Not sure how it would - // work in Lambda, so for now, let's await the result of the request, and update form submission - // logs accordingly. + /** + * Could be executed without awaiting the end result of the trigger? Not sure how it would + * work in Lambda, so for now, let's await the result of the request, and update form submission + * logs accordingly. + */ try { const response = await got(url, { method: "post", diff --git a/packages/api-form-builder/src/plugins/upgrades/index.ts b/packages/api-form-builder/src/plugins/upgrades/index.ts index d7eda387e16..d662567faa5 100644 --- a/packages/api-form-builder/src/plugins/upgrades/index.ts +++ b/packages/api-form-builder/src/plugins/upgrades/index.ts @@ -1,3 +1,4 @@ -import upgrade500 from "./v5.0.0"; - -export default [upgrade500]; +/** + * Placeholder for the future upgrades. + */ +export default []; diff --git a/packages/api-form-builder/src/plugins/upgrades/utils.ts b/packages/api-form-builder/src/plugins/upgrades/utils.ts deleted file mode 100644 index 93878097513..00000000000 --- a/packages/api-form-builder/src/plugins/upgrades/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const paginateBatch = async (items, perPage, execute) => { - const pages = Math.ceil(items.length / perPage); - - for (let i = 0; i < pages; i++) { - await execute(items.slice(i * perPage, i * perPage + perPage)); - } -}; diff --git a/packages/api-form-builder/src/plugins/upgrades/v5.0.0/index.ts b/packages/api-form-builder/src/plugins/upgrades/v5.0.0/index.ts deleted file mode 100644 index 0f00a66fc91..00000000000 --- a/packages/api-form-builder/src/plugins/upgrades/v5.0.0/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { UpgradePlugin } from "@webiny/api-upgrade/types"; -import { FormBuilderContext } from "../../../types"; -import { paginateBatch } from "../utils"; -import defaults from "../../crud/defaults"; - -const plugin: UpgradePlugin = { - name: "api-upgrade-form-builder", - type: "api-upgrade", - app: "form-builder", - version: "5.0.0", - async apply(context) { - const { elasticsearch, fileManager, db } = context; - const limit = 1000; - let hasMoreItems = true; - let after = undefined; - let esItems = []; - - while (hasMoreItems) { - const response = await elasticsearch.search({ - ...defaults.es(context), - body: { - sort: { - createdOn: { - order: "asc", - // eslint-disable-next-line - unmapped_type: "date" - } - }, - size: limit + 1, - after - } - }); - const { hits } = response.body.hits; - - hasMoreItems = hits.length > limit; - after = hasMoreItems ? hits[limit - 1].sort : undefined; - esItems = [...esItems, ...hits.filter(item => !item._id.includes("T#root#"))]; - } - - console.log(`Fetched ${esItems.length} items from Elasticsearch`); - if (esItems.length === 0) { - return; - } - - // Store a backup of old items - const esJSON = JSON.stringify(esItems); - - const { file } = await fileManager.storage.storagePlugin.upload({ - name: "upgrade-form-builder-es-5.0.0.json", - type: "application/json", - size: esJSON.length, - buffer: Buffer.from(esJSON) - }); - - console.log(`Stored backup of Elasticsearch items to ${file.key}`); - - // Store items to ES DDB table - await paginateBatch(esItems, 25, async items => { - await db - .batch() - .create( - ...items.map(item => { - if (item._source.__type === "fb.form") { - const [uniqueId] = item._source.id.split("#"); - return { - ...defaults.esDb, - data: { - PK: `T#root#L#${item._source.locale}#FB#F#${uniqueId}`, - SK: "L", - index: item._index, - data: item._source, - savedOn: new Date().toISOString(), - version: "5.0.0" - } - }; - } - - // __type: "fb.submission" - return { - ...defaults.esDb, - data: { - PK: `T#root#L#${item._source.locale}#FB#F#${item._source.form.parent}`, - SK: `FS#${item._source.id}`, - index: item._index, - data: item._source, - savedOn: new Date().toISOString() - } - }; - }) - ) - .execute(); - }); - - console.log(`Inserted items into Elasticsearch DynamoDB table.`); - - // Delete original items from ES index - const operations = esItems.map(item => { - return { delete: { _id: item._id, _index: item._index } }; - }); - - const { - body: { items, errors } - } = await elasticsearch.bulk({ - body: operations, - // eslint-disable-next-line - filter_path: "errors,items.*.error" - }); - - console.log(`Deleted old Elasticsearch items from "root-form-builder" index.`); - - if (errors) { - console.warn("These items were not deleted", items); - } - } -}; - -export default plugin; - -// Target _id: T#root#L#en-US#FB#F#603e248212ee4400089d16eb:L - -// const record = { -// _index: "root-form-builder", -// _type: "_doc", -// _id: "FORM#L#6040a8a5a6180e00085d168e", -// _score: 1.0, -// _source: { -// __type: "fb.form", -// id: "6040a8a5a6180e00085d168e#0001", -// createdOn: "2021-03-04T09:30:13.784Z", -// savedOn: "2021-03-04T09:30:13.784Z", -// name: "Test", -// slug: "test-6040a8a5a6180e00085d168e", -// published: false, -// publishedOn: null, -// version: 1, -// locked: false, -// status: "draft", -// createdBy: { -// id: "admin@webiny.com", -// displayName: "Pavel Denisjuk", -// type: "admin" -// }, -// ownedBy: { -// id: "admin@webiny.com", -// displayName: "Pavel Denisjuk", -// type: "admin" -// }, -// locale: "en-US" -// } -// }; diff --git a/packages/api-form-builder/src/plugins/validators/in.ts b/packages/api-form-builder/src/plugins/validators/in.ts index 9a23359700d..ca6e0b50d10 100644 --- a/packages/api-form-builder/src/plugins/validators/in.ts +++ b/packages/api-form-builder/src/plugins/validators/in.ts @@ -1,5 +1,5 @@ import { validation } from "@webiny/validation"; -import { FbFormFieldValidatorPlugin } from "../../types"; +import { FbFormFieldValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator", diff --git a/packages/api-form-builder/src/plugins/validators/lte.ts b/packages/api-form-builder/src/plugins/validators/lte.ts index 76bed544922..f713950de66 100644 --- a/packages/api-form-builder/src/plugins/validators/lte.ts +++ b/packages/api-form-builder/src/plugins/validators/lte.ts @@ -1,5 +1,5 @@ import { validation } from "@webiny/validation"; -import { FbFormFieldValidatorPlugin } from "../../types"; +import { FbFormFieldValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator", diff --git a/packages/api-form-builder/src/plugins/validators/maxLength.ts b/packages/api-form-builder/src/plugins/validators/maxLength.ts index 43405e2c2cf..8f07a253107 100644 --- a/packages/api-form-builder/src/plugins/validators/maxLength.ts +++ b/packages/api-form-builder/src/plugins/validators/maxLength.ts @@ -1,5 +1,5 @@ import { validation } from "@webiny/validation"; -import { FbFormFieldValidatorPlugin } from "../../types"; +import { FbFormFieldValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator", diff --git a/packages/api-form-builder/src/plugins/validators/minLength.ts b/packages/api-form-builder/src/plugins/validators/minLength.ts index 571f3fdf016..4c5ed7a1994 100644 --- a/packages/api-form-builder/src/plugins/validators/minLength.ts +++ b/packages/api-form-builder/src/plugins/validators/minLength.ts @@ -1,5 +1,5 @@ import { validation } from "@webiny/validation"; -import { FbFormFieldValidatorPlugin } from "../../types"; +import { FbFormFieldValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator", diff --git a/packages/api-form-builder/src/plugins/validators/pattern.ts b/packages/api-form-builder/src/plugins/validators/pattern.ts index a13a0adec7e..e844bdb43f7 100644 --- a/packages/api-form-builder/src/plugins/validators/pattern.ts +++ b/packages/api-form-builder/src/plugins/validators/pattern.ts @@ -2,7 +2,7 @@ * Since form-field-validator plugin needs access to the request context, we create a context plugin which * registers the actual validation plugin with access to the request context. */ -import { FbFormFieldPatternValidatorPlugin } from "../../types"; +import { FbFormFieldPatternValidatorPlugin } from "~/types"; import { ContextPlugin } from "@webiny/handler/types"; export default { diff --git a/packages/api-form-builder/src/plugins/validators/patternPlugins/email.ts b/packages/api-form-builder/src/plugins/validators/patternPlugins/email.ts index c0799150813..c754f6f608d 100644 --- a/packages/api-form-builder/src/plugins/validators/patternPlugins/email.ts +++ b/packages/api-form-builder/src/plugins/validators/patternPlugins/email.ts @@ -1,4 +1,4 @@ -import { FbFormFieldPatternValidatorPlugin } from "../../../types"; +import { FbFormFieldPatternValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator-pattern", diff --git a/packages/api-form-builder/src/plugins/validators/patternPlugins/lowerCase.ts b/packages/api-form-builder/src/plugins/validators/patternPlugins/lowerCase.ts index 16e12e47cc8..206caa238de 100644 --- a/packages/api-form-builder/src/plugins/validators/patternPlugins/lowerCase.ts +++ b/packages/api-form-builder/src/plugins/validators/patternPlugins/lowerCase.ts @@ -1,4 +1,4 @@ -import { FbFormFieldPatternValidatorPlugin } from "../../../types"; +import { FbFormFieldPatternValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator-pattern", diff --git a/packages/api-form-builder/src/plugins/validators/patternPlugins/upperCase.ts b/packages/api-form-builder/src/plugins/validators/patternPlugins/upperCase.ts index 36fefaff294..60a5d4882bd 100644 --- a/packages/api-form-builder/src/plugins/validators/patternPlugins/upperCase.ts +++ b/packages/api-form-builder/src/plugins/validators/patternPlugins/upperCase.ts @@ -1,4 +1,4 @@ -import { FbFormFieldPatternValidatorPlugin } from "../../../types"; +import { FbFormFieldPatternValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator-pattern", diff --git a/packages/api-form-builder/src/plugins/validators/patternPlugins/url.ts b/packages/api-form-builder/src/plugins/validators/patternPlugins/url.ts index 12205557bb6..9ab2062099e 100644 --- a/packages/api-form-builder/src/plugins/validators/patternPlugins/url.ts +++ b/packages/api-form-builder/src/plugins/validators/patternPlugins/url.ts @@ -1,4 +1,4 @@ -import { FbFormFieldPatternValidatorPlugin } from "../../../types"; +import { FbFormFieldPatternValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator-pattern", diff --git a/packages/api-form-builder/src/plugins/validators/required.ts b/packages/api-form-builder/src/plugins/validators/required.ts index a4686fe364a..a4572edc586 100644 --- a/packages/api-form-builder/src/plugins/validators/required.ts +++ b/packages/api-form-builder/src/plugins/validators/required.ts @@ -1,5 +1,5 @@ import { validation } from "@webiny/validation"; -import { FbFormFieldValidatorPlugin } from "../../types"; +import { FbFormFieldValidatorPlugin } from "~/types"; export default { type: "fb-form-field-validator", diff --git a/packages/api-form-builder/src/types.ts b/packages/api-form-builder/src/types.ts index 49b2a6171c8..0bdcb85964d 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -1,56 +1,61 @@ import { Plugin } from "@webiny/plugins/types"; -import { Context } from "@webiny/handler/types"; -import { TenancyContext } from "@webiny/api-tenancy/types"; +import { TenancyContext, Tenant } from "@webiny/api-tenancy/types"; import { I18NContentContext } from "@webiny/api-i18n-content/types"; import { ElasticsearchContext } from "@webiny/api-elasticsearch/types"; import { SecurityPermission } from "@webiny/api-security/types"; import { FileManagerContext } from "@webiny/api-file-manager/types"; import { I18NContext } from "@webiny/api-i18n/types"; +import { Topic } from "@webiny/pubsub/types"; +import { UpgradePlugin } from "@webiny/api-upgrade/types"; -type FbFormTriggerData = Record; -type FbSubmissionData = Record; +interface FbFormTriggerData { + [key: string]: any; +} +interface FbSubmissionData { + [key: string]: any; +} -type FbFormFieldValidator = { +interface FbFormFieldValidator { name: string; message: any; settings: Record; -}; +} -export type FbFormFieldValidatorPlugin = Plugin & { +export interface FbFormFieldValidatorPlugin extends Plugin { type: "fb-form-field-validator"; validator: { name: string; validate: (value: any, validator: FbFormFieldValidator) => Promise; }; -}; +} -export type FbFormFieldPatternValidatorPlugin = Plugin & { +export interface FbFormFieldPatternValidatorPlugin extends Plugin { type: "fb-form-field-validator-pattern"; pattern: { name: string; regex: string; flags: string; }; -}; +} -export type FbFormTriggerHandlerParams = { +export interface FbFormTriggerHandlerParams { addLog: (log: Record) => void; trigger: FbFormTriggerData; data: FbSubmissionData; form: FbForm; -}; +} /** * Used to define custom business logic that gets executed upon successful form submission (e.g. send data to a specific e-mail address). * @see https://docs.webiny.com/docs/webiny-apps/form-builder/development/plugins-reference/api#form-trigger-handler */ -export type FbFormTriggerHandlerPlugin = Plugin & { +export interface FbFormTriggerHandlerPlugin extends Plugin { type: "form-trigger-handler"; trigger: string; handle: (args: FbFormTriggerHandlerParams) => Promise; -}; +} -export type FbForm = { +export interface FbForm { id: string; tenant: string; locale: string; @@ -67,53 +72,61 @@ export type FbForm = { status: string; fields: Record[]; layout: string[][]; - stats: Record; + stats: Omit; settings: Record; triggers: Record; -}; + formId: string; + webinyVersion: string; +} -export type CreatedBy = { +export interface CreatedBy { id: string; displayName: string; type: string; -}; +} export type OwnedBy = CreatedBy; -type FormCreateInput = { +interface FormCreateInput { name: string; -}; +} -type FormUpdateInput = { +interface FormUpdateInput { name: string; fields: Record[]; layout: string[][]; settings: Record; triggers: Record; -}; +} -type FbFormStats = { +export interface FbFormStats { submissions: number; views: number; conversionRate: number; -}; - -type FbSubmissionsSort = Record<"createdOn", 1 | -1>; +} -type FbListSubmissionsOptions = { +interface FbListSubmissionsOptions { limit?: number; after?: string; - sort?: FbSubmissionsSort; -}; + sort?: string[]; +} -export type FbListSubmissionsMeta = { +export interface FbListSubmissionsMeta { cursor: string; hasMoreItems: boolean; totalCount: number; -}; +} + +export interface FormBuilderGetFormOptions { + auth?: boolean; +} + +export interface FormBuilderGetFormRevisionsOptions { + auth?: boolean; +} -export type FormsCRUD = { - getForm(id: string): Promise; +export interface FormsCRUD { + getForm(id: string, options?: FormBuilderGetFormOptions): Promise; getFormStats(id: string): Promise; listForms(): Promise; createForm(data: FormCreateInput): Promise; @@ -124,11 +137,14 @@ export type FormsCRUD = { createFormRevision(fromRevisionId: string): Promise; incrementFormViews(id: string): Promise; incrementFormSubmissions(id: string): Promise; - getFormRevisions(id: string): Promise; + getFormRevisions(id: string, options?: FormBuilderGetFormRevisionsOptions): Promise; getPublishedFormRevisionById(revisionId: string): Promise; getLatestPublishedFormRevision(formId: string): Promise; - deleteRevision(id: string): Promise; - getSubmissionsByIds(formId: string, submissionIds: string[]): Promise; + deleteFormRevision(id: string): Promise; +} + +export interface SubmissionsCRUD { + getSubmissionsByIds(form: string | FbForm, submissionIds: string[]): Promise; listFormSubmissions( formId: string, options: FbListSubmissionsOptions @@ -141,16 +157,33 @@ export type FormsCRUD = { ): Promise; updateSubmission(formId: string, data: FbSubmission): Promise; deleteSubmission(formId: string, submissionId: string): Promise; -}; +} -export type SystemCRUD = { - getVersion(): Promise; - setVersion(version: string): Promise; - install(args: { domain?: string }): Promise; - upgrade(version: string, data?: Record): Promise; -}; +export interface BeforeInstallTopic { + tenant: Tenant; +} -export type FbSubmission = { +export interface AfterInstallTopic { + tenant: Tenant; +} + +export interface SystemCRUD { + /** + * @internal + */ + getSystem(): Promise; + getSystemVersion(): Promise; + setSystemVersion(version: string): Promise; + installSystem(args: { domain?: string }): Promise; + upgradeSystem(version: string, data?: Record): Promise; + /** + * Events + */ + onBeforeInstall: Topic; + onAfterInstall: Topic; +} + +export interface FbSubmission { id: string; locale: string; ownedBy: OwnedBy; @@ -165,33 +198,48 @@ export type FbSubmission = { layout: string[][]; }; logs: Record[]; -}; + createdOn: string; + savedOn: string; + webinyVersion: string; + tenant: string; +} -export type SubmissionInput = { +export interface SubmissionInput { data: Record; meta: Record; reCaptchaResponseToken: string; -}; +} -export type SubmissionUpdateData = { +export interface SubmissionUpdateData { logs: Record; -}; +} -export type Settings = { - key: "form-builder"; +/** + * @category Settings + * @category DataModel + */ +export interface Settings { domain: string; reCaptcha: { enabled: boolean; siteKey: string; secretKey: string; }; -}; + tenant: string; + locale: string; +} + +export interface SettingsCRUDGetParams { + auth?: boolean; + throwOnNotFound?: boolean; +} -export type SettingsCRUD = { - getSettings(options?: { auth: boolean }): Promise; +export interface SettingsCRUD { + getSettings(params?: SettingsCRUDGetParams): Promise; createSettings(data: Partial): Promise; updateSettings(data: Partial): Promise; -}; + deleteSettings(): Promise; +} export interface FbFormPermission extends SecurityPermission { name: "fb.form"; @@ -205,17 +253,378 @@ export interface FbFormSettingsPermission extends SecurityPermission { name: "fb.settings"; } -export type FormBuilderContext = Context< - TenancyContext, - I18NContext, - I18NContentContext, - FileManagerContext, - ElasticsearchContext, - { - formBuilder: { - forms: FormsCRUD; - settings: SettingsCRUD; - system: SystemCRUD; - }; - } ->; +/** + * The object representing form builder internals. + */ +export interface FormBuilder extends SystemCRUD, SettingsCRUD, FormsCRUD, SubmissionsCRUD { + storageOperations: FormBuilderStorageOperations; +} + +export interface FormBuilderContext + extends TenancyContext, + I18NContext, + I18NContentContext, + FileManagerContext, + ElasticsearchContext { + /** + * + */ + formBuilder: FormBuilder; +} +/** + * @category System + * @category DataModel + */ +export interface System { + version?: string; + tenant: string; +} +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category System + */ +export interface FormBuilderStorageOperationsGetSystemParams { + tenant: string; +} +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category System + */ +export interface FormBuilderStorageOperationsCreateSystemParams { + system: System; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category System + */ +export interface FormBuilderStorageOperationsUpdateSystemParams { + original: System; + system: System; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category Settings + */ +export interface FormBuilderStorageOperationsGetSettingsParams { + tenant: string; + locale: string; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category Settings + */ +export interface FormBuilderStorageOperationsCreateSettingsParams { + settings: Settings; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category Settings + */ +export interface FormBuilderStorageOperationsUpdateSettingsParams { + original: Settings; + settings: Settings; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + * @category Settings + */ +export interface FormBuilderStorageOperationsDeleteSettingsParams { + settings: Settings; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsGetFormParams { + where: { + id?: string; + formId?: string; + version?: number; + published?: boolean; + latest?: boolean; + tenant: string; + locale: string; + }; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsListFormsParams { + where: { + id?: string; + version?: number; + slug?: string; + published?: boolean; + ownedBy?: string; + latest?: boolean; + tenant: string; + locale: string; + }; + after: string | null; + limit: number; + sort: string[]; +} +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsListFormRevisionsParams { + where: { + id?: string; + formId?: string; + version_not?: number; + publishedOn_not?: string | null; + tenant: string; + locale: string; + }; + sort?: string[]; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsListFormsResponse { + items: FbForm[]; + meta: { + hasMoreItems: boolean; + cursor: string | null; + totalCount: number; + }; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsCreateFormParams { + input: Record; + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsCreateFormFromParams { + original: FbForm; + latest: FbForm; + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsUpdateFormParams { + input?: Record; + original: FbForm; + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsDeleteFormParams { + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsDeleteFormRevisionParams { + /** + * Method always receives all the revisions of given form ordered by version_DESC. + */ + revisions: FbForm[]; + /** + * Previous revision of the current form. Always the first lesser available version. + */ + previous: FbForm; + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsPublishFormParams { + original: FbForm; + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsUnpublishFormParams { + original: FbForm; + form: FbForm; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsGetSubmissionParams { + where: { + id?: string; + formId?: string; + tenant: string; + locale: string; + }; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsListSubmissionsParams { + where: { + id_in?: string[]; + parent: string; + locale: string; + tenant: string; + }; + after?: string; + limit?: number; + sort?: string[]; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsCreateSubmissionParams { + input: Record; + form: FbForm; + submission: FbSubmission; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsUpdateSubmissionParams { + input: Record; + form: FbForm; + original: FbSubmission; + submission: FbSubmission; +} +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsDeleteSubmissionParams { + form: FbForm; + submission: FbSubmission; +} + +/** + * @category StorageOperations + */ +export interface FormBuilderSystemStorageOperations { + getSystem(params: FormBuilderStorageOperationsGetSystemParams): Promise; + createSystem(params: FormBuilderStorageOperationsCreateSystemParams): Promise; + updateSystem(params: FormBuilderStorageOperationsUpdateSystemParams): Promise; +} + +/** + * @category StorageOperations + */ +export interface FormBuilderSettingsStorageOperations { + getSettings(params: FormBuilderStorageOperationsGetSettingsParams): Promise; + createSettings(params: FormBuilderStorageOperationsCreateSettingsParams): Promise; + updateSettings(params: FormBuilderStorageOperationsUpdateSettingsParams): Promise; + deleteSettings(params: FormBuilderStorageOperationsDeleteSettingsParams): Promise; +} + +/** + * @category StorageOperations + */ +export interface FormBuilderFormStorageOperations { + getForm(params: FormBuilderStorageOperationsGetFormParams): Promise; + listForms( + params: FormBuilderStorageOperationsListFormsParams + ): Promise; + listFormRevisions( + params: FormBuilderStorageOperationsListFormRevisionsParams + ): Promise; + createForm(params: FormBuilderStorageOperationsCreateFormParams): Promise; + createFormFrom(params: FormBuilderStorageOperationsCreateFormFromParams): Promise; + updateForm(params: FormBuilderStorageOperationsUpdateFormParams): Promise; + /** + * Delete all form revisions + latest + published. + */ + deleteForm(params: FormBuilderStorageOperationsDeleteFormParams): Promise; + /** + * Delete the single form revision. + */ + deleteFormRevision( + params: FormBuilderStorageOperationsDeleteFormRevisionParams + ): Promise; + publishForm(params: FormBuilderStorageOperationsPublishFormParams): Promise; + unpublishForm(params: FormBuilderStorageOperationsUnpublishFormParams): Promise; +} + +/** + * @category StorageOperations + */ +export interface FormBuilderStorageOperationsListSubmissionsResponse { + items: FbSubmission[]; + meta: FbListSubmissionsMeta; +} + +/** + * @category StorageOperations + */ +export interface FormBuilderSubmissionStorageOperations { + getSubmission(params: FormBuilderStorageOperationsGetSubmissionParams): Promise; + listSubmissions( + params: FormBuilderStorageOperationsListSubmissionsParams + ): Promise; + createSubmission( + params: FormBuilderStorageOperationsCreateSubmissionParams + ): Promise; + updateSubmission( + params: FormBuilderStorageOperationsUpdateSubmissionParams + ): Promise; + deleteSubmission( + params: FormBuilderStorageOperationsDeleteSubmissionParams + ): Promise; +} +/** + * @category StorageOperations + */ +export interface FormBuilderStorageOperations + extends FormBuilderSystemStorageOperations, + FormBuilderSettingsStorageOperations, + FormBuilderFormStorageOperations, + FormBuilderSubmissionStorageOperations { + /** + * We can initialize what ever we require in this method. + * Initially it was intended to attach events like afterInstall, beforeInstall, etc... + */ + init?: (formBuilder: FormBuilder) => Promise; + /** + * An upgrade to run if necessary. + */ + upgrade?: UpgradePlugin | null; +} diff --git a/packages/api-form-builder/tsconfig.build.json b/packages/api-form-builder/tsconfig.build.json index 565879d1ca0..4c8c87db953 100644 --- a/packages/api-form-builder/tsconfig.build.json +++ b/packages/api-form-builder/tsconfig.build.json @@ -11,22 +11,24 @@ "../api-security", "../api-upgrade", "../error", + "../utils", + "../pubsub", "../handler", "../handler-graphql", "../validation", "../api-i18n-content", "../handler-aws", - "../handler-db", "../api-elasticsearch", "../api-dynamodb-to-elasticsearch", - "../db-dynamodb", "../api-tenancy" ], "compilerOptions": { "rootDir": "./src", "outDir": "./dist", "declarationDir": "./dist", - "paths": {}, + "paths": { + "~/*": ["./src/*"] + }, "baseUrl": "." }, "references": [] diff --git a/packages/api-form-builder/tsconfig.json b/packages/api-form-builder/tsconfig.json index cd1c6cdd6ed..869f72968a5 100644 --- a/packages/api-form-builder/tsconfig.json +++ b/packages/api-form-builder/tsconfig.json @@ -29,19 +29,19 @@ "path": "../api-upgrade" }, { - "path": "../db-dynamodb" + "path": "../error" }, { - "path": "../error" + "path": "../utils" }, { - "path": "../handler" + "path": "../pubsub" }, { - "path": "../handler-aws" + "path": "../handler" }, { - "path": "../handler-db" + "path": "../handler-aws" }, { "path": "../handler-graphql" @@ -58,6 +58,7 @@ ], "compilerOptions": { "paths": { + "~/*": ["./src/*"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], "@webiny/api-i18n/*": ["../api-i18n/src/*"], @@ -74,15 +75,16 @@ "@webiny/api-tenancy": ["../api-tenancy/src"], "@webiny/api-upgrade/*": ["../api-upgrade/src/*"], "@webiny/api-upgrade": ["../api-upgrade/src"], - "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], - "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], "@webiny/handler-aws": ["../handler-aws/src"], - "@webiny/handler-db/*": ["../handler-db/src/*"], - "@webiny/handler-db": ["../handler-db/src"], "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], diff --git a/packages/api-headless-cms-ddb-es/src/configurations.ts b/packages/api-headless-cms-ddb-es/src/configurations.ts index d1d270ad66f..b208b66d874 100644 --- a/packages/api-headless-cms-ddb-es/src/configurations.ts +++ b/packages/api-headless-cms-ddb-es/src/configurations.ts @@ -28,7 +28,7 @@ interface Configurations { const configurations: Configurations = { db: () => ({ - table: process.env.DB_TABLE_HEADLESS_CMS, + table: process.env.DB_TABLE_HEADLESS_CMS || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/api-headless-cms-ddb-es/src/helpers/createElasticsearchQueryBody.ts b/packages/api-headless-cms-ddb-es/src/helpers/createElasticsearchQueryBody.ts index 9f0b31c2e55..cb25524683a 100644 --- a/packages/api-headless-cms-ddb-es/src/helpers/createElasticsearchQueryBody.ts +++ b/packages/api-headless-cms-ddb-es/src/helpers/createElasticsearchQueryBody.ts @@ -23,6 +23,7 @@ import { decodeCursor } from "@webiny/api-elasticsearch/cursors"; import { createSort } from "@webiny/api-elasticsearch/sort"; import { createModelFields, ModelField, ModelFields } from "./fields"; import { CmsEntryElasticsearchFieldPlugin } from "~/plugins/CmsEntryElasticsearchFieldPlugin"; +import { parseWhereKey } from "@webiny/api-elasticsearch/where"; interface CreateElasticsearchParams { context: CmsContext; @@ -52,25 +53,6 @@ interface CreateElasticsearchQueryArgs { const specialFields = ["published", "latest"]; const noKeywordFields = ["date", "number", "boolean"]; -const parseWhereKeyRegExp = new RegExp(/^([a-zA-Z0-9]+)(_[a-zA-Z0-9_]+)?$/); - -const parseWhereKey = (key: string) => { - const match = key.match(parseWhereKeyRegExp); - - if (!match) { - throw new Error(`It is not possible to search by key "${key}"`); - } - - const [, field, operation = "eq"] = match; - const op = operation.match(/^_/) ? operation.substr(1) : operation; - - if (!field.match(/^([a-zA-Z]+)$/)) { - throw new Error(`Cannot filter by "${field}".`); - } - - return { field, op }; -}; - const createElasticsearchSortParams = (args: CreateElasticsearchSortParams): esSort => { const { context, sort, modelFields, parentPath, searchPlugins } = args; @@ -78,7 +60,9 @@ const createElasticsearchSortParams = (args: CreateElasticsearchSortParams): esS return undefined; } - const sortPlugins = Object.values(modelFields).reduce((plugins, modelField) => { + const sortPlugins: Record = Object.values( + modelFields + ).reduce((plugins, modelField) => { const searchPlugin = searchPlugins[modelField.type]; plugins[modelField.field.fieldId] = new CmsEntryElasticsearchFieldPlugin({ @@ -98,8 +82,7 @@ const createElasticsearchSortParams = (args: CreateElasticsearchSortParams): esS }, {}); return createSort({ - context, - plugins: sortPlugins, + fieldPlugins: sortPlugins, sort }); }; @@ -248,7 +231,7 @@ const execElasticsearchBuildQueryPlugins = ( if (where[key] === undefined) { continue; } - const { field, op } = parseWhereKey(key); + const { field, operator } = parseWhereKey(key); const modelField = modelFields[field]; if (!modelField) { @@ -258,10 +241,10 @@ const execElasticsearchBuildQueryPlugins = ( if (!isSearchable) { throw new WebinyError(`Field "${field}" is not searchable.`); } - const plugin = operatorPlugins[op]; + const plugin = operatorPlugins[operator]; if (!plugin) { throw new WebinyError("Operator plugin missing.", "PLUGIN_MISSING", { - operator: op + operator }); } const fieldSearchPlugin = searchPlugins[modelField.type]; @@ -283,7 +266,6 @@ const execElasticsearchBuildQueryPlugins = ( basePath: fieldPath, path: keyword ? `${fieldPath}.keyword` : fieldPath, value, - context, keyword }); } diff --git a/packages/api-headless-cms-ddb/src/configurations.ts b/packages/api-headless-cms-ddb/src/configurations.ts index 1a10f028020..7fed5d28108 100644 --- a/packages/api-headless-cms-ddb/src/configurations.ts +++ b/packages/api-headless-cms-ddb/src/configurations.ts @@ -20,7 +20,7 @@ interface Configurations { const configurations: Configurations = { db: () => ({ - table: process.env.DB_TABLE_HEADLESS_CMS, + table: process.env.DB_TABLE_HEADLESS_CMS || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts index 42e07f6cc49..b833ca5358f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts @@ -1,4 +1,4 @@ -import { CmsContentModelGroup } from "../../src/types"; +import { CmsContentModelGroup } from "~/types"; import { useContentGqlHandler } from "../utils/useContentGqlHandler"; import models from "./mocks/contentModels"; import { useCategoryManageHandler } from "../utils/useCategoryManageHandler"; @@ -149,6 +149,16 @@ describe("READ - Resolvers", () => { // Create an entry const [create] = await createCategory({ data: { title: "Title 1", slug: "slug-1" } }); + + expect(create).toEqual({ + data: { + createCategory: { + data: expect.any(Object), + error: null + } + } + }); + const category = create.data.createCategory.data; const { id: categoryId } = category; diff --git a/packages/api-i18n-ddb/src/operations/configurations.ts b/packages/api-i18n-ddb/src/operations/configurations.ts index 7f6c832a272..5b31a116e4d 100644 --- a/packages/api-i18n-ddb/src/operations/configurations.ts +++ b/packages/api-i18n-ddb/src/operations/configurations.ts @@ -1,6 +1,6 @@ export default { db: () => ({ - table: process.env.DB_TABLE_I18N, + table: process.env.DB_TABLE_I18N || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/api-page-builder-so-ddb-es/src/operations/configurations.ts b/packages/api-page-builder-so-ddb-es/src/operations/configurations.ts index e072e2be9db..9cfefbb081d 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/configurations.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/configurations.ts @@ -3,7 +3,7 @@ import { PbContext } from "@webiny/api-page-builder/graphql/types"; export default { db: () => ({ - table: process.env.DB_TABLE_PAGE_BUILDER, + table: process.env.DB_TABLE_PAGE_BUILDER || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/elasticsearchQueryBody.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/elasticsearchQueryBody.ts index eb843171ed7..977a0351abe 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pages/elasticsearchQueryBody.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/elasticsearchQueryBody.ts @@ -11,30 +11,13 @@ import { PageElasticsearchFieldPlugin } from "~/plugins/definitions/PageElastics import { PageElasticsearchSortModifierPlugin } from "~/plugins/definitions/PageElasticsearchSortModifierPlugin"; import { PageElasticsearchQueryModifierPlugin } from "~/plugins/definitions/PageElasticsearchQueryModifierPlugin"; import { PageElasticsearchBodyModifierPlugin } from "~/plugins/definitions/PageElasticsearchBodyModifierPlugin"; +import { applyWhere } from "@webiny/api-elasticsearch/where"; interface CreateElasticsearchQueryArgs { context: PbContext; where: PageStorageOperationsListWhere; } -const parseWhereKeyRegExp = new RegExp(/^([a-zA-Z0-9]+)(_[a-zA-Z0-9_]+)?$/); -const parseWhereKey = (key: string) => { - const match = key.match(parseWhereKeyRegExp); - - if (!match) { - throw new Error(`It is not possible to search by key "${key}"`); - } - - const [, field, operation = "eq"] = match; - const op = operation.match(/^_/) ? operation.substr(1) : operation; - - if (!field.match(/^([a-zA-Z]+)$/)) { - throw new Error(`Cannot filter by "${field}".`); - } - - return { field, op }; -}; - /** * Latest and published are specific in Elasticsearch to that extend that they are tagged in the published or latest property. * We allow either published or either latest. @@ -100,32 +83,6 @@ const createInitialQueryValue = ( return query; }; -const findFieldPlugin = ( - plugins: Record, - field: string -): ElasticsearchFieldPlugin => { - const plugin = plugins[field] || plugins["*"]; - if (plugin) { - return plugin; - } - throw new WebinyError(`Missing plugin for the field "${field}".`, "PLUGIN_ERROR", { - field - }); -}; - -const findOperatorPlugin = ( - plugins: Record, - operator: string -): ElasticsearchQueryBuilderOperatorPlugin => { - const fieldPlugin = plugins[operator]; - if (fieldPlugin) { - return fieldPlugin; - } - throw new WebinyError(`Missing plugin for the operator "${operator}"`, "PLUGIN_ERROR", { - operator - }); -}; - interface CreateElasticsearchBodyParams { context: PbContext; where: PageStorageOperationsListWhere; @@ -216,45 +173,12 @@ const createElasticsearchQuery = ( /** * We apply other conditions as they are passed via the where value. */ - for (const key in where) { - if (where.hasOwnProperty(key) === false) { - continue; - } - const initialValue = where[key]; - /** - * There is a possibility that undefined is sent as a value, so just skip it. - */ - if (initialValue === undefined) { - continue; - } - const { field, op } = parseWhereKey(key); - const fieldPlugin = findFieldPlugin(fieldPlugins, field); - const operatorPlugin = findOperatorPlugin(operatorPlugins, op); - - /** - * Get the path but in the case of * (all fields, replace * with the field. - * Custom path would return its own value anyways. - */ - const path = fieldPlugin.getPath(field); - const basePath = fieldPlugin.getBasePath(field); - /** - * Transform the value for the search. - */ - const value = fieldPlugin.toSearchValue({ - context, - value: initialValue, - path, - basePath - }); - - operatorPlugin.apply(query, { - context, - value, - path, - basePath: basePath, - keyword: fieldPlugin.keyword - }); - } + applyWhere({ + query, + where, + fields: fieldPlugins, + operators: operatorPlugins + }); return query; }; @@ -287,9 +211,8 @@ export const createElasticsearchQueryBody = ( }); const sort = createSort({ - context, sort: initialSort, - plugins: fieldPlugins + fieldPlugins }); const queryModifiers = context.plugins.byType( @@ -297,7 +220,6 @@ export const createElasticsearchQueryBody = ( ); for (const plugin of queryModifiers) { plugin.modifyQuery({ - context, query, where }); @@ -308,7 +230,6 @@ export const createElasticsearchQueryBody = ( ); for (const plugin of sortModifiers) { plugin.modifySort({ - context, sort }); } @@ -338,7 +259,6 @@ export const createElasticsearchQueryBody = ( ); for (const plugin of bodyModifiers) { plugin.modifyBody({ - context, body }); } diff --git a/packages/api-security-admin-users-so-ddb/src/configurations.ts b/packages/api-security-admin-users-so-ddb/src/configurations.ts index cdbcd3eda9a..7113b63aeb7 100644 --- a/packages/api-security-admin-users-so-ddb/src/configurations.ts +++ b/packages/api-security-admin-users-so-ddb/src/configurations.ts @@ -9,7 +9,7 @@ interface Configurations { const configurations: Configurations = { db: () => ({ - table: process.env.DB_TABLE_SECURITY, + table: process.env.DB_TABLE_SECURITY || process.env.DB_TABLE, keys: [ { primary: true, diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/useSubmissions.ts b/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/useSubmissions.ts index 2ee8a110cb3..064296d4963 100644 --- a/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/useSubmissions.ts +++ b/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/useSubmissions.ts @@ -13,9 +13,7 @@ export default form => { }, exportInProgress: false, submissions: [], - sort: { - createdOn: -1 - } + sort: ["createdOn_DESC"] }); const client = useApolloClient(); diff --git a/packages/app-form-builder/src/admin/plugins/installation.tsx b/packages/app-form-builder/src/admin/plugins/installation.tsx index 698f3b56b69..c4d271a86bc 100644 --- a/packages/app-form-builder/src/admin/plugins/installation.tsx +++ b/packages/app-form-builder/src/admin/plugins/installation.tsx @@ -100,6 +100,12 @@ const plugin: AdminInstallationPlugin = { getComponent() { return lazy(() => import("./upgrades/v5.0.0")); } + }, + { + version: "5.16.0", + getComponent() { + return lazy(() => import("./upgrades/v5.16.0")); + } } ] }; diff --git a/packages/app-form-builder/src/admin/plugins/upgrades/v5.16.0.tsx b/packages/app-form-builder/src/admin/plugins/upgrades/v5.16.0.tsx new file mode 100644 index 00000000000..62422e54702 --- /dev/null +++ b/packages/app-form-builder/src/admin/plugins/upgrades/v5.16.0.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useState } from "react"; +import gql from "graphql-tag"; +import { i18n } from "@webiny/app/i18n"; +import { + SimpleForm, + SimpleFormContent, + SimpleFormFooter, + SimpleFormHeader +} from "@webiny/app-admin/components/SimpleForm"; +import { useApolloClient } from "@apollo/react-hooks"; +import { Alert } from "@webiny/ui/Alert"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import { Typography } from "@webiny/ui/Typography"; + +const t = i18n.ns("app-headless-cms/admin/installation"); + +const UPGRADE = gql` + mutation UpgradeFormBuilder($version: String!) { + formBuilder { + upgrade(version: $version) { + data + error { + code + message + data + } + } + } + } +`; + +const Upgrade = ({ onInstalled }) => { + const client = useApolloClient(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const startUpgrade = useCallback(async () => { + setLoading(true); + await client + .mutate({ + mutation: UPGRADE, + variables: { + version: "5.16.0" + } + }) + .then(({ data }) => { + setLoading(false); + const { error } = data.formBuilder.upgrade; + if (error) { + setError(error.message); + return; + } + + onInstalled(); + }); + }, []); + + const label = error ? ( + + {error} + + ) : ( + t`Upgrading Form Builder...` + ); + + return ( + + {loading && } + + + + + + This upgrade will do the following: +
    +
  • + add `formId` and `webinyVersion` fields to DynamoDB form records +
  • +
+
+
+
+
+ + Upgrade + +
+ ); +}; + +export default Upgrade; diff --git a/packages/cli/commands/upgrade/upgrades/5.16.0/index.js b/packages/cli/commands/upgrade/upgrades/5.16.0/index.js new file mode 100644 index 00000000000..4992ac6e911 --- /dev/null +++ b/packages/cli/commands/upgrade/upgrades/5.16.0/index.js @@ -0,0 +1,253 @@ +/** + * A new type of upgrade where we take the files from cwp-template-aws and copy them into required locations. + * Old files are always backed up. + */ +const { prettierFormat, yarnInstall } = require("../utils"); +const path = require("path"); +const fs = require("fs"); + +const targetVersion = "5.16.0"; + +const checkFiles = files => { + for (const initialFile of files) { + const file = createFullFile(initialFile); + if (!fs.existsSync(file)) { + /** + * We throw error because if any of the files does not exist, it should not go any further. + */ + throw new Error(`There is no file "${file}".`); + } + } +}; + +const createBackupFileName = file => { + const ext = `.${file.split(".").pop()}`; + + const now = Math.floor(Date.now() / 1000); + + const backup = file.replace(new RegExp(`${ext}$`), `.${now}${ext}`); + + const backupFile = createFullFile(backup); + if (!fs.existsSync(backupFile)) { + return backup; + } + throw new Error(`Backup file "${backupFile}" already exists.`); +}; + +const createFullFile = file => { + return path.join(process.cwd(), file); +}; +/** + * + * @param context {CliContext} + * @param initialTargets {{source: string, destination: string}[]} + */ +const copyFiles = (context, initialTargets) => { + context.info("Copying files..."); + /** + * First check if source and target files exist and create a backup file name. + * @type {{source: string, destination: string, backup: string}[]} + */ + const targets = []; + for (const target of initialTargets) { + /** + * + */ + checkFiles([target.source, target.destination]); + let backup; + try { + backup = createBackupFileName(target.destination); + } catch (ex) { + context.error(ex.message); + process.exit(1); + } + + targets.push({ + source: target.source, + destination: target.destination, + backup + }); + } + /** + * Then: + * - make backups of the targets files + * - copy new files to their destinations + */ + const backups = []; + context.info("Creating backups..."); + for (const target of targets) { + try { + fs.copyFileSync(createFullFile(target.destination), createFullFile(target.backup)); + context.info(`Backed up "${target.destination}" to "${target.backup}".`); + backups.push(target.backup); + } catch (ex) { + context.error(`Could not create backup "${target.destination}" to "${target.backup}".`); + for (const backup of backups) { + context.info(`Removing created backup "${backup}".`); + fs.unlinkSync(createFullFile(backup)); + } + process.exit(1); + } + } + + const files = []; + context.info("Copying new files..."); + for (const target of targets) { + try { + fs.copyFileSync(createFullFile(target.source), createFullFile(target.destination)); + context.info(`Copying new file "${target.source}" to "${target.destination}".`); + files.push({ + destination: target.destination, + backup: target.backup + }); + } catch (ex) { + context.error(`Could not copy new file "${target.source}" to "${target.destination}".`); + for (const file of files) { + context.info(`Restoring backup file "${file.backup}" to "${file.destination}".`); + fs.copyFileSync(createFullFile(file.backup), createFullFile(file.destination)); + fs.unlinkSync(createFullFile(file.backup)); + } + process.exit(1); + } + } + context.info("File copying complete!"); +}; + +/** + * + * @param context {CliContext} + * @param initialTargets {{source: string, destination: string}[]} + */ +const assignPackageVersions = (context, initialTargets) => { + const targets = initialTargets + .filter(target => target.destination.match(/package\.json$/) !== null) + .map(target => target.destination); + if (targets.length === 0) { + return; + } + context.info("Assigning proper package versions..."); + for (const target of targets) { + const file = path.join(process.cwd(), target); + try { + const json = JSON.parse(fs.readFileSync(file).toString()); + /** + * + * @type {{}} + */ + json.dependencies = Object.keys(json.dependencies).reduce((dependencies, key) => { + if (key.match(/^@webiny\//) === null) { + dependencies[key] = json.dependencies[key]; + return dependencies; + } else if (json.dependencies[key] === "latest") { + dependencies[key] = `^${targetVersion}`; + } else { + dependencies[key] = json.dependencies[key]; + } + + return dependencies; + }, {}); + /** + * + */ + if (json.devDependencies) { + json.devDependencies = Object.keys(json.devDependencies).reduce( + (dependencies, key) => { + if (key.match(/^@webiny\//) === null) { + dependencies[key] = json.devDependencies[key]; + return dependencies; + } else if (json.devDependencies[key] === "latest") { + dependencies[key] = `^${targetVersion}`; + } else { + dependencies[key] = json.devDependencies[key]; + } + + return dependencies; + }, + {} + ); + } + fs.writeFileSync(file, JSON.stringify(json)); + } catch (ex) { + console.error(ex.message); + } + } +}; + +/** + * @type {CliUpgradePlugin} + */ +module.exports = { + name: `upgrade-${targetVersion}`, + type: "cli-upgrade", + version: targetVersion, + /** + * @param options {CliUpgradePluginOptions} + * @param context {CliContext} + * @returns {Promise} + */ + async canUpgrade(options, context) { + if (context.version === targetVersion) { + return true; + } else if ( + context.version.match( + new RegExp( + /** + * This is for beta testing. + */ + `^${targetVersion}-` + ) + ) + ) { + return true; + } + /** + * We throw error here because it should not go further if version is not good. + */ + throw new Error( + `Upgrade must be on Webiny CLI version "${targetVersion}". Current CLI version is "${context.version}".` + ); + }, + /** + * @param options {CliUpgradePluginOptions} + * @param context {CliContext} + * @returns {Promise} + */ + async upgrade(options, context) { + const targets = [ + { + source: "node_modules/@webiny/cwp-template-aws/template/api/code/graphql/src/index.ts", + destination: "api/code/graphql/src/index.ts" + }, + { + source: "node_modules/@webiny/cwp-template-aws/template/api/code/graphql/package.json", + destination: "api/code/graphql/package.json" + } + ]; + /** + * Copy new files to their destinations. + */ + copyFiles(context, targets); + /** + * If any package.json destinations, set the versions to current one. + */ + assignPackageVersions(context, targets); + + await prettierFormat( + targets.map(t => t.destination), + context + ); + + /** + * Install new packages. + */ + await yarnInstall({ + context + }); + + context.info("\n"); + context.info("Existing files were backed up and new ones created."); + context.info( + "You must transfer the custom parts of the code from the backed up files if you want everything to work properly." + ); + } +}; diff --git a/packages/cli/commands/upgrade/upgrades/upgrade.js b/packages/cli/commands/upgrade/upgrades/upgrade.js index 05e7fe5aead..a9cdeb8883c 100644 --- a/packages/cli/commands/upgrade/upgrades/upgrade.js +++ b/packages/cli/commands/upgrade/upgrades/upgrade.js @@ -1 +1 @@ -module.exports = require("./5.15.0"); +module.exports = require("./5.16.0"); diff --git a/packages/cwp-template-aws/template/api/code/graphql/package.json b/packages/cwp-template-aws/template/api/code/graphql/package.json index f056e6f9c1f..1ae5999eb31 100644 --- a/packages/cwp-template-aws/template/api/code/graphql/package.json +++ b/packages/cwp-template-aws/template/api/code/graphql/package.json @@ -10,6 +10,7 @@ "@webiny/api-file-manager-ddb-es": "latest", "@webiny/api-file-manager-s3": "latest", "@webiny/api-form-builder": "latest", + "@webiny/api-form-builder-so-ddb-es": "latest", "@webiny/api-i18n": "latest", "@webiny/api-i18n-ddb": "latest", "@webiny/api-i18n-content": "latest", diff --git a/packages/cwp-template-aws/template/api/code/graphql/src/index.ts b/packages/cwp-template-aws/template/api/code/graphql/src/index.ts index 36f03f582f6..dcfb1fc7593 100644 --- a/packages/cwp-template-aws/template/api/code/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/api/code/graphql/src/index.ts @@ -17,7 +17,8 @@ import fileManagerDynamoDbElasticPlugins from "@webiny/api-file-manager-ddb-es"; import prerenderingServicePlugins from "@webiny/api-prerendering-service/client"; import logsPlugins from "@webiny/handler-logs"; import fileManagerS3 from "@webiny/api-file-manager-s3"; -import formBuilderPlugins from "@webiny/api-form-builder/plugins"; +import { createFormBuilder } from "@webiny/api-form-builder"; +import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb-es"; import securityPlugins from "./security"; import headlessCmsPlugins from "@webiny/api-headless-cms/plugins"; import headlessCmsDynamoDbElasticStorageOperation from "@webiny/api-headless-cms-ddb-es"; @@ -25,23 +26,28 @@ import elasticsearchDataGzipCompression from "@webiny/api-elasticsearch/plugins/ // Imports plugins created via scaffolding utilities. import scaffoldsPlugins from "./plugins/scaffolds"; +import { createElasticsearchClient } from "@webiny/api-elasticsearch/client"; const debug = process.env.DEBUG === "true"; +const documentClient = new DocumentClient({ + convertEmptyValues: true, + region: process.env.AWS_REGION +}); + +const elasticsearchClient = createElasticsearchClient({ + endpoint: `https://${process.env.ELASTIC_SEARCH_ENDPOINT}` +}); + export const handler = createHandler({ plugins: [ logsPlugins(), graphqlPlugins({ debug }), - elasticsearchClientContextPlugin({ - endpoint: `https://${process.env.ELASTIC_SEARCH_ENDPOINT}` - }), + elasticsearchClientContextPlugin(elasticsearchClient), dbPlugins({ table: process.env.DB_TABLE, driver: new DynamoDbDriver({ - documentClient: new DocumentClient({ - convertEmptyValues: true, - region: process.env.AWS_REGION - }) + documentClient }) }), securityPlugins(), @@ -67,7 +73,12 @@ export const handler = createHandler({ pageBuilderPlugins(), pageBuilderDynamoDbElasticsearchPlugins(), pageBuilderPrerenderingPlugins(), - formBuilderPlugins(), + createFormBuilder({ + storageOperations: createFormBuilderStorageOperations({ + elasticsearch: elasticsearchClient, + documentClient + }) + }), headlessCmsPlugins(), headlessCmsDynamoDbElasticStorageOperation(), scaffoldsPlugins(), diff --git a/packages/db-dynamodb/src/plugins/definitions/FieldPlugin.ts b/packages/db-dynamodb/src/plugins/definitions/FieldPlugin.ts index 9023752807c..1c56b67a157 100644 --- a/packages/db-dynamodb/src/plugins/definitions/FieldPlugin.ts +++ b/packages/db-dynamodb/src/plugins/definitions/FieldPlugin.ts @@ -3,6 +3,10 @@ import { DynamoDBTypes } from "dynamodb-toolbox/dist/classes/Table"; export type FieldType = DynamoDBTypes & "date" & any; +export interface TransformValueCb { + (value: any): any; +} + export interface Params { /** * Default is string. @@ -14,6 +18,8 @@ export interface Params { * Default is true. */ sortable?: boolean; + + transformValue?: TransformValueCb; } export abstract class FieldPlugin extends Plugin { @@ -22,6 +28,7 @@ export abstract class FieldPlugin extends Plugin { private readonly fieldType: FieldType; private readonly dynamoDbType: DynamoDBTypes; private readonly sortable: boolean; + private readonly _transformValue: TransformValueCb | undefined; public constructor(params: Params) { super(); @@ -30,6 +37,7 @@ export abstract class FieldPlugin extends Plugin { this.field = params.field; this.path = params.path; this.sortable = params.sortable === undefined ? true : params.sortable; + this._transformValue = params.transformValue; } public getPath(): string { @@ -45,10 +53,16 @@ export abstract class FieldPlugin extends Plugin { } public transformValue(value: any): any { + if (this._transformValue) { + return this.transformValue(value); + } switch (this.fieldType) { case "number": return Number(value); case "date": + if (!value) { + return null; + } return new Date(value); } return value; diff --git a/packages/db-dynamodb/src/utils/sort.ts b/packages/db-dynamodb/src/utils/sort.ts index b3f30c45609..a4d92d01980 100644 --- a/packages/db-dynamodb/src/utils/sort.ts +++ b/packages/db-dynamodb/src/utils/sort.ts @@ -2,16 +2,17 @@ import lodashOrderBy from "lodash/orderBy"; import WebinyError from "@webiny/error"; import { FieldPlugin } from "~/plugins/definitions/FieldPlugin"; -interface Sorters { +interface Info { sorters: string[]; orders: string[]; } -interface ExtractSortResult { +interface Response { reverse: boolean; field: string; } -const extractSort = (sortBy: string, fields: FieldPlugin[]): ExtractSortResult => { + +const extractSort = (sortBy: string, fields: FieldPlugin[]): Response => { const result = sortBy.split("_"); if (result.length !== 2) { throw new WebinyError( @@ -32,7 +33,8 @@ const extractSort = (sortBy: string, fields: FieldPlugin[]): ExtractSortResult = }); } const fieldPlugin = fields.find(f => f.getField() === field); - if (!fieldPlugin || fieldPlugin.isSortable() === false) { + const isSortable = fieldPlugin ? fieldPlugin.isSortable() : true; + if (isSortable === false) { throw new WebinyError(`Cannot sort by given field: "${field}".`, "UNSUPPORTED_SORT_ERROR", { fields, field @@ -59,6 +61,7 @@ interface Params { */ fields: FieldPlugin[]; } + export const sortItems = (params: Params): T[] => { const { items, sort: initialSort = [], fields } = params; if (items.length <= 1) { @@ -67,12 +70,15 @@ export const sortItems = (params: Params): T[] => { initialSort.push("createdOn_DESC"); } - const info: Sorters = { + const info: Info = { sorters: [], orders: [] }; for (const sort of initialSort) { + /** + * Possibly empty array item was passed. + */ if (!sort) { continue; } @@ -84,5 +90,9 @@ export const sortItems = (params: Params): T[] => { info.orders.push(reverse === true ? "desc" : "asc"); } + if (info.sorters.length === 0) { + return items; + } + return lodashOrderBy(items, info.sorters, info.orders); }; diff --git a/packages/handler-db/src/index.ts b/packages/handler-db/src/index.ts index 1bc81ca4021..ff6735a8384 100644 --- a/packages/handler-db/src/index.ts +++ b/packages/handler-db/src/index.ts @@ -1,17 +1,14 @@ import { Db } from "@webiny/db"; -import { ContextPlugin } from "@webiny/handler/types"; +import { ContextPlugin } from "@webiny/handler/plugins/ContextPlugin"; import { DbContext } from "./types"; export default args => { return [ - { - type: "context", - apply(context) { - if (context.db) { - return; - } - context.db = new Db(args); + new ContextPlugin(context => { + if (context.db) { + return; } - } as ContextPlugin + context.db = new Db(args); + }) ]; }; diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 68592b2406a..45cd0c25a09 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -16,6 +16,9 @@ "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.5.5", + "@babel/preset-env": "^7.5.5", + "@babel/preset-typescript": "^7.8.3", + "@babel/runtime": "^7.5.5", "@webiny/cli": "^5.13.0", "@webiny/project-utils": "^5.13.0", "rimraf": "^3.0.2", diff --git a/packages/utils/.babelrc.js b/packages/utils/.babelrc.js new file mode 100644 index 00000000000..7cdc243c30a --- /dev/null +++ b/packages/utils/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("../../.babel.node")({ path: __dirname }); diff --git a/packages/utils/LICENSE b/packages/utils/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/utils/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/utils/README.md b/packages/utils/README.md new file mode 100644 index 00000000000..b8ff31759dd --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,25 @@ +# @webiny/utils + +[![](https://img.shields.io/npm/dw/@webiny/utils.svg)](https://www.npmjs.com/package/@webiny/utils) +[![](https://img.shields.io/npm/v/@webiny/utils.svg)](https://www.npmjs.com/package/@webiny/utils) +[![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) + +## About + +Small package containing the methods that are used throughout our other packages. +* for example, zeroPad adds zeros to the start of the version number. + +## Install + +``` +yarn add @webiny/utils +``` + + +## Testing + +### Command +```` +yarn test packages/utils +```` \ No newline at end of file diff --git a/packages/utils/__tests__/createIdentifier.test.ts b/packages/utils/__tests__/createIdentifier.test.ts new file mode 100644 index 00000000000..94cded687f7 --- /dev/null +++ b/packages/utils/__tests__/createIdentifier.test.ts @@ -0,0 +1,22 @@ +import { createIdentifier } from "~/createIdentifier"; + +describe("create identifier", () => { + const inputs = [ + ["aaaaa", 1, "aaaaa#0001"], + ["bbbbb#0005", 17, "bbbbb#0017"], + ["ccccc#0018", 319, "ccccc#0319"], + ["ddddd#0501", 7049, "ddddd#7049"] + ]; + + test.each(inputs)( + `must create full identifier from "%s" and "%s"`, + (id: string, version: number, expected: string) => { + const result = createIdentifier({ + id, + version + }); + + expect(result).toEqual(expected); + } + ); +}); diff --git a/packages/utils/__tests__/parseIdentifier.test.ts b/packages/utils/__tests__/parseIdentifier.test.ts new file mode 100644 index 00000000000..6a87b513804 --- /dev/null +++ b/packages/utils/__tests__/parseIdentifier.test.ts @@ -0,0 +1,47 @@ +import { parseIdentifier } from "~/parseIdentifier"; + +describe("parse identifier", () => { + const inputs = [ + [ + "aaaaa", + { + id: "aaaaa", + version: null + } + ], + [ + "bbbbb#0001", + { + id: "bbbbb", + version: 1 + } + ], + [ + "ccccc#0017", + { + id: "ccccc", + version: 17 + } + ], + [ + "ddddd#0917", + { + id: "ddddd", + version: 917 + } + ], + [ + "eeeee#7917", + { + id: "eeeee", + version: 7917 + } + ] + ]; + + test.each(inputs)(`must parse identifier from "%s"`, (identifier: string, expected: any) => { + const result = parseIdentifier(identifier); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/utils/__tests__/zeroPad.test.ts b/packages/utils/__tests__/zeroPad.test.ts new file mode 100644 index 00000000000..8219b672b7b --- /dev/null +++ b/packages/utils/__tests__/zeroPad.test.ts @@ -0,0 +1,17 @@ +import { zeroPad } from "~/zeroPad"; + +describe("zero pad", () => { + const inputs = [ + [1, "0001"], + [12, "0012"], + [123, "0123"], + ["1234", "1234"], + [12345, "12345"] + ]; + + test.each(inputs)(`should pad "%s" with zeros`, (input: any, expected: string) => { + const result = zeroPad(input); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js new file mode 100644 index 00000000000..cc5ac2bb64f --- /dev/null +++ b/packages/utils/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base"); + +module.exports = { + ...base({ path: __dirname }) +}; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 00000000000..8a7a3b55887 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,33 @@ +{ + "name": "@webiny/utils", + "version": "5.15.0", + "main": "index.js", + "types": "index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git", + "directory": "packages/utils" + }, + "description": "A collection of simple helper utils that are used throughout our system. Use internally only.", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "devDependencies": { + "@babel/cli": "^7.12.10", + "@babel/core": "^7.5.5", + "@babel/preset-env": "^7.5.5", + "@babel/preset-typescript": "^7.8.3", + "@babel/runtime": "^7.5.5", + "@webiny/cli": "^5.13.0", + "@webiny/project-utils": "^5.13.0", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.12", + "typescript": "^4.1.3" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + } +} diff --git a/packages/utils/src/createIdentifier.ts b/packages/utils/src/createIdentifier.ts new file mode 100644 index 00000000000..0f6f8ac006f --- /dev/null +++ b/packages/utils/src/createIdentifier.ts @@ -0,0 +1,20 @@ +import { zeroPad } from "~/zeroPad"; +import { parseIdentifier } from "~/parseIdentifier"; + +/** + * Used to create the identifier that is an absolute unique for the record. + * It is created out of the generated ID and version of the record. + * + * + * The input ID is being parsed as you might send a full ID instead of only the generated one. + */ +export interface CreateIdentifierParams { + id: string; + version: number; +} + +export const createIdentifier = (values: CreateIdentifierParams): string => { + const { id } = parseIdentifier(values.id); + + return `${id}#${zeroPad(values.version)}`; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 00000000000..cc0dd3bfd06 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,5 @@ +import { parseIdentifier } from "~/parseIdentifier"; +import { zeroPad } from "~/zeroPad"; +import { createIdentifier } from "~/createIdentifier"; + +export { parseIdentifier, zeroPad, createIdentifier }; diff --git a/packages/utils/src/parseIdentifier.ts b/packages/utils/src/parseIdentifier.ts new file mode 100644 index 00000000000..93500dbaa3e --- /dev/null +++ b/packages/utils/src/parseIdentifier.ts @@ -0,0 +1,17 @@ +/** + * When you want to extract the generated ID and version out of the identifier string. + * In case there is no version, it's not a problem, possibly only generated ID was sent. + * It does not cause an error. Write check for that in the code using this fn. + */ +export interface ParseIdentifierResult { + id: string; + version: number | null; +} + +export const parseIdentifier = (value: string): ParseIdentifierResult => { + const [id, version] = value.split("#"); + return { + id, + version: version ? Number(version) : null + }; +}; diff --git a/packages/utils/src/zeroPad.ts b/packages/utils/src/zeroPad.ts new file mode 100644 index 00000000000..c3cae47d866 --- /dev/null +++ b/packages/utils/src/zeroPad.ts @@ -0,0 +1,7 @@ +/** + * Used when we need to create an ID of some data record. + * Or, for example, when adding the revision record to the DynamoDB table. + */ +export const zeroPad = (version: number, amount = 4): string => { + return `${version}`.padStart(amount, "0"); +}; diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json new file mode 100644 index 00000000000..4615d573cc6 --- /dev/null +++ b/packages/utils/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["./src"], + "exclude": ["node_modules", "./dist"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"] + }, + "baseUrl": "." + }, + "references": [] +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 00000000000..be13e36b4df --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig", + "references": [], + "include": ["./src", "./__tests__"], + "compilerOptions": { + "paths": { + "~/*": ["./src/*"] + }, + "baseUrl": "." + } +} diff --git a/packages/utils/webiny.config.js b/packages/utils/webiny.config.js new file mode 100644 index 00000000000..d2680341ca2 --- /dev/null +++ b/packages/utils/webiny.config.js @@ -0,0 +1,8 @@ +const { watchPackage, buildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: buildPackage, + watch: watchPackage + } +}; diff --git a/yarn.lock b/yarn.lock index 82e680919fd..e147e602a07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,29 +80,29 @@ __metadata: linkType: hard "@aws-amplify/auth@npm:^4.0.2": - version: 4.3.0 - resolution: "@aws-amplify/auth@npm:4.3.0" + version: 4.2.1 + resolution: "@aws-amplify/auth@npm:4.2.1" dependencies: - "@aws-amplify/cache": 4.0.12 - "@aws-amplify/core": 4.2.4 - amazon-cognito-identity-js: 5.1.0 + "@aws-amplify/cache": 4.0.11 + "@aws-amplify/core": 4.2.3 + amazon-cognito-identity-js: 5.0.6 crypto-js: ^4.1.1 - checksum: c4014477e1f934b2097b60acf7990fa8d39e47aec73ce84d5f4c60507c6d1dc6cf418a214887dc2a4e30de963a3aeb8be72b4fbb1fbb6e77aab77a14d9353aa3 + checksum: a2dea317248f81a463607c000bd4cdcd1a2c2dd52b2622b44f28b572df7d0130d0bc1cfa47d4a13861da052458e8177189038b04e94926a319d5c7b3b82fb161 languageName: node linkType: hard -"@aws-amplify/cache@npm:4.0.12": - version: 4.0.12 - resolution: "@aws-amplify/cache@npm:4.0.12" +"@aws-amplify/cache@npm:4.0.11": + version: 4.0.11 + resolution: "@aws-amplify/cache@npm:4.0.11" dependencies: - "@aws-amplify/core": 4.2.4 - checksum: af795635bbd979817c1ac7aed857dfd45706c61f02fb6c8d9ea9a6840764ee277e2db9d9ec710c61c9c294b98d1d91f35811c5fda7f26c439766b89975dcfa46 + "@aws-amplify/core": 4.2.3 + checksum: 151ece65c6fc63f1d67b0fa8939f1f023f2add02e8aae09252509f965dccbc542530424e0a1305a42a383dbaf36646b96160da8b4d12328492069b9f8ebcd950 languageName: node linkType: hard -"@aws-amplify/core@npm:4.2.4": - version: 4.2.4 - resolution: "@aws-amplify/core@npm:4.2.4" +"@aws-amplify/core@npm:4.2.3": + version: 4.2.3 + resolution: "@aws-amplify/core@npm:4.2.3" dependencies: "@aws-crypto/sha256-js": 1.0.0-alpha.0 "@aws-sdk/client-cloudwatch-logs": 3.6.1 @@ -112,7 +112,7 @@ __metadata: "@aws-sdk/util-hex-encoding": 3.6.1 universal-cookie: ^4.0.4 zen-observable-ts: 0.8.19 - checksum: 9d264426a28da1a99464269393c2d9294bcde531f231a82eca5447100aaabd2f6bd27fe9af1bacb5b9127d792d1c19de671d0fbf0abccc1d7956356c11fc631e + checksum: 18a3ff67c7564228b742d24327f39890fe78f9d1e3c1ddcf2161574a4fc4c00e4a5b3fa86f1a87bfb6dd6f78b056f11f6498313d0990cfa60a15777d39acc174 languageName: node linkType: hard @@ -2981,8 +2981,8 @@ __metadata: linkType: hard "@cypress/request@npm:^2.88.5": - version: 2.88.6 - resolution: "@cypress/request@npm:2.88.6" + version: 2.88.5 + resolution: "@cypress/request@npm:2.88.5" dependencies: aws-sign2: ~0.7.0 aws4: ^1.8.0 @@ -2997,13 +2997,14 @@ __metadata: isstream: ~0.1.2 json-stringify-safe: ~5.0.1 mime-types: ~2.1.19 + oauth-sign: ~0.9.0 performance-now: ^2.1.0 qs: ~6.5.2 safe-buffer: ^5.1.2 tough-cookie: ~2.5.0 tunnel-agent: ^0.6.0 - uuid: ^8.3.2 - checksum: 31d4586e212e20955091cdf67a0de746c2587f97f7ff0e2b8e84e772d98a73a1c6051c9090dd938ecdf595c37aec9193b6211faf754b5fcf43c7a151b4d274b7 + uuid: ^3.3.2 + checksum: a605f8a623f4665402768f4d7730315a420967d41c44194eeb2a946ce0b74ce3eb8205a73b0cab879fcf65870dbb1189ac60ea67d163c7acd64228e39e65611a languageName: node linkType: hard @@ -3714,6 +3715,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:^27.2.4": + version: 27.2.4 + resolution: "@jest/environment@npm:27.2.4" + dependencies: + "@jest/fake-timers": ^27.2.4 + "@jest/types": ^27.2.4 + "@types/node": "*" + jest-mock: ^27.2.4 + checksum: 67d762d093f6eeb43fb1eefb2217136408a749eba1a8318ceea9b198d35552c10f76af543e0f062f3c5b1e939ef62eb1cfc9cee099e9e1913ba482044ccad806 + languageName: node + linkType: hard + "@jest/fake-timers@npm:^26.6.2": version: 26.6.2 resolution: "@jest/fake-timers@npm:26.6.2" @@ -3742,6 +3755,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:^27.2.4": + version: 27.2.4 + resolution: "@jest/fake-timers@npm:27.2.4" + dependencies: + "@jest/types": ^27.2.4 + "@sinonjs/fake-timers": ^8.0.1 + "@types/node": "*" + jest-message-util: ^27.2.4 + jest-mock: ^27.2.4 + jest-util: ^27.2.4 + checksum: 5123f63cbe21d1d577b9d800ce3cd20e72811d7a4e3f05cace84334bd8bb28b778c2d4ae33004ee24469867e0f80efb1eb70517a840dfa12d42991c53ebf7640 + languageName: node + linkType: hard + "@jest/globals@npm:^26.6.2": version: 26.6.2 resolution: "@jest/globals@npm:26.6.2" @@ -3919,6 +3946,19 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:^27.2.4": + version: 27.2.4 + resolution: "@jest/types@npm:27.2.4" + dependencies: + "@types/istanbul-lib-coverage": ^2.0.0 + "@types/istanbul-reports": ^3.0.0 + "@types/node": "*" + "@types/yargs": ^16.0.0 + chalk: ^4.0.0 + checksum: 0d34189874354a63bc80eeb99da75078ea8a65599c6cd0b937cf1909fc9d490f99adf5aa32ca5a67735496f131491f323b750983d471ecbbcd3e3fec618b01df + languageName: node + linkType: hard + "@lerna/add@npm:3.21.0": version: 3.21.0 resolution: "@lerna/add@npm:3.21.0" @@ -5393,8 +5433,8 @@ __metadata: linkType: hard "@octokit/app@npm:^12.0.0": - version: 12.0.4 - resolution: "@octokit/app@npm:12.0.4" + version: 12.0.3 + resolution: "@octokit/app@npm:12.0.3" dependencies: "@octokit/auth-app": ^3.3.0 "@octokit/auth-unauthenticated": ^2.0.4 @@ -5403,7 +5443,7 @@ __metadata: "@octokit/plugin-paginate-rest": ^2.13.3 "@octokit/types": ^6.13.0 "@octokit/webhooks": ^9.0.1 - checksum: 7f81201f9512b6362f011e1b2fc1bb735f488eb12ce0f4acc53f59efb9db5e5f215101281cbc67f06eafdddb877c266c22629c8b38f1b8a49e6a982094247c79 + checksum: bd9ca5ca5a21bbde75d38fe4aed5bb40a57d270f51bfbbac7c1e129d19cc15bb240ae006def86fed9d45737db05bf5892e46a29907fb1b65191302f79749acac languageName: node linkType: hard @@ -5919,8 +5959,8 @@ __metadata: linkType: hard "@pulumi/aws@npm:^4.10.0": - version: 4.16.0 - resolution: "@pulumi/aws@npm:4.16.0" + version: 4.15.0 + resolution: "@pulumi/aws@npm:4.15.0" dependencies: "@pulumi/pulumi": ^3.0.0 aws-sdk: ^2.0.0 @@ -5928,13 +5968,13 @@ __metadata: mime: ^2.0.0 read-package-tree: ^5.2.1 resolve: ^1.7.1 - checksum: d73644549480873e5dad18df2f5372836fa8b5dec5fedcfcf2523b32baff3077f3a99ea10491fa876b6e4c619c209e5e3698994020f4d5abdad6844bbe141d8c + checksum: ea9bec7b1818651f09bedb8cf658f6f43b18c9b6ab31f13eef920d5c6a6776573ba63e3ab29f7d5c8861fc6550f1f1e5371c52bfaeb7612c9925f8e24ce19552 languageName: node linkType: hard "@pulumi/pulumi@npm:^3.0.0, @pulumi/pulumi@npm:^3.6.0": - version: 3.10.3 - resolution: "@pulumi/pulumi@npm:3.10.3" + version: 3.10.1 + resolution: "@pulumi/pulumi@npm:3.10.1" dependencies: "@grpc/grpc-js": ^1.2.7 "@logdna/tail-file": ^2.0.6 @@ -5951,7 +5991,7 @@ __metadata: ts-node: ^7.0.1 typescript: ~3.7.3 upath: ^1.1.0 - checksum: 6df958eae12dda5ac3694e4fca684886b17ac153af88f6a15ff6597b4d11f1cf635e5c5a88fbf6b4e47a053a9172b64659b4fa58c54d6f70cec74512145cc601 + checksum: 6ef6c1c441daed54ed86e94ad106f657bdef73728fc6f5ce245972d06b5fcf180032cb1159927b0d2ab66af99c03bc74478c3af4d3fddfda2c7d67cbe8f72e2d languageName: node linkType: hard @@ -6533,6 +6573,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^8.0.1": + version: 8.0.1 + resolution: "@sinonjs/fake-timers@npm:8.0.1" + dependencies: + "@sinonjs/commons": ^1.7.0 + checksum: 97a78e6f83dd420d73b155a0438cd0fd3392e706b8314530db3d99354689cc714eb3d18540be2aedcd3a3d6070e14f509dce7c6cc817701e9538b3b8ac423eaa + languageName: node + linkType: hard + "@sinonjs/samsam@npm:^5.3.1": version: 5.3.1 resolution: "@sinonjs/samsam@npm:5.3.1" @@ -7426,11 +7475,11 @@ __metadata: linkType: hard "@types/jsonwebtoken@npm:^8.3.3, @types/jsonwebtoken@npm:^8.5.1": - version: 8.5.5 - resolution: "@types/jsonwebtoken@npm:8.5.5" + version: 8.5.4 + resolution: "@types/jsonwebtoken@npm:8.5.4" dependencies: "@types/node": "*" - checksum: 33c30354641bc7849be7507e9f48685b1f487e944321a932650eac6c247c85184667f5e207ccfcab0da8cb24bde93a8372c09cacf1849e976bbf2cb90b26ce90 + checksum: 541d41eb8115afd422e0333d7ba79399febc86818015b3ecb7c36b2b7f9b4476a33aa13a4f23f81a08b201c5cdacbe2903b6d03cd4930abf83a4e14be3696719 languageName: node linkType: hard @@ -7505,9 +7554,9 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:>= 8, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0, @types/node@npm:>=6": - version: 16.6.2 - resolution: "@types/node@npm:16.6.2" - checksum: 2245e50058ac49ab3d76af5ded7fc655d783a88a800139dad6caaf962f15c909287853012c9461b49600741bcc2b09042f94ce734f0440b6ad444d838e62904a + version: 16.6.1 + resolution: "@types/node@npm:16.6.1" + checksum: c13aa0da0c2bf9070e521d1b537ba38f64b213dd1b8aeec66c279fc21f8c0ccaf380364f6dc2ec8f84440c4cd1460b67dfe47de2cafcb6eaf4b648817cb27dc4 languageName: node linkType: hard @@ -7854,11 +7903,11 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^4.14.1": - version: 4.29.2 - resolution: "@typescript-eslint/eslint-plugin@npm:4.29.2" + version: 4.29.1 + resolution: "@typescript-eslint/eslint-plugin@npm:4.29.1" dependencies: - "@typescript-eslint/experimental-utils": 4.29.2 - "@typescript-eslint/scope-manager": 4.29.2 + "@typescript-eslint/experimental-utils": 4.29.1 + "@typescript-eslint/scope-manager": 4.29.1 debug: ^4.3.1 functional-red-black-tree: ^1.0.1 regexpp: ^3.1.0 @@ -7870,23 +7919,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 3d3646059daa3d95200d71945a1ec8daebf62c7fedc3f29e1bece87bee9d689b06856fb18a8c55917f9c0bb5e86ddc8bc4c4f65f171e7d5784756dd59e3ff51d + checksum: 66d8a92a0dc6fe4353334b5fadda73231dc7dbea977bc41f867bf9907e6781dc9594401273280d8de88165223bbf131241a1646ff5f2246bb4289949f95e7a5d languageName: node linkType: hard -"@typescript-eslint/experimental-utils@npm:4.29.2": - version: 4.29.2 - resolution: "@typescript-eslint/experimental-utils@npm:4.29.2" +"@typescript-eslint/experimental-utils@npm:4.29.1": + version: 4.29.1 + resolution: "@typescript-eslint/experimental-utils@npm:4.29.1" dependencies: "@types/json-schema": ^7.0.7 - "@typescript-eslint/scope-manager": 4.29.2 - "@typescript-eslint/types": 4.29.2 - "@typescript-eslint/typescript-estree": 4.29.2 + "@typescript-eslint/scope-manager": 4.29.1 + "@typescript-eslint/types": 4.29.1 + "@typescript-eslint/typescript-estree": 4.29.1 eslint-scope: ^5.1.1 eslint-utils: ^3.0.0 peerDependencies: eslint: "*" - checksum: e07b6b58f386ba84801d10bfe494548c3af20448c2f5596b77d13ba8621345ced4e1c6cf946dcf118c1e8566e0eed8284200f3f3a96f89aa7f367d9cdf6549a3 + checksum: 4ab7827edfb42aa01a34b342656a2c6ef6ac753bff7523cffe6e2bd50de8c86c304abc8a790f11c2b02172436d1cc941ff26aa96a0b40ccaffc02f032faf6582 languageName: node linkType: hard @@ -7905,36 +7954,36 @@ __metadata: linkType: hard "@typescript-eslint/parser@npm:^4.14.1": - version: 4.29.2 - resolution: "@typescript-eslint/parser@npm:4.29.2" + version: 4.29.1 + resolution: "@typescript-eslint/parser@npm:4.29.1" dependencies: - "@typescript-eslint/scope-manager": 4.29.2 - "@typescript-eslint/types": 4.29.2 - "@typescript-eslint/typescript-estree": 4.29.2 + "@typescript-eslint/scope-manager": 4.29.1 + "@typescript-eslint/types": 4.29.1 + "@typescript-eslint/typescript-estree": 4.29.1 debug: ^4.3.1 peerDependencies: eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 59f9727cea89c208fb31433c24dd7c1b4f2feb3af831b9320f4577f7b84f014f803864d4660b0f6bd16a4026d7ecd22b88523feb8c1593ef4a0a43ca9ea09c33 + checksum: 227119d9f7e406b741662417570034396c8ea14016e07c64caf3b8cc8973d6ccc92f98e84cf53cf96cf26825ec62873e0a4dd745f6d1043ce54498d1eec7a20e languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:4.29.2": - version: 4.29.2 - resolution: "@typescript-eslint/scope-manager@npm:4.29.2" +"@typescript-eslint/scope-manager@npm:4.29.1": + version: 4.29.1 + resolution: "@typescript-eslint/scope-manager@npm:4.29.1" dependencies: - "@typescript-eslint/types": 4.29.2 - "@typescript-eslint/visitor-keys": 4.29.2 - checksum: f89d11cf7ce28c37a913db432d3dd2c4e5f5bc431bac205dd55c3d49704be691a28d5f27ae96fde7feee23d3e80192d7aff3d8350aef53b415e5b0b53cd965d7 + "@typescript-eslint/types": 4.29.1 + "@typescript-eslint/visitor-keys": 4.29.1 + checksum: e8d34ac72f5184c93e45785f50c80bdb7602814cfeacd6c700bca4f4e482fddc3f0191a6fc36782e19ffeef15791b0792b26c940c1d3c0e549d7dc1dc1ccc983 languageName: node linkType: hard -"@typescript-eslint/types@npm:4.29.2": - version: 4.29.2 - resolution: "@typescript-eslint/types@npm:4.29.2" - checksum: 0bcab66bb1848e2361bb366abebe1f94baa56d7d2058b62467f14c054b969b72d1aa17717a52c11f48e9cfb50846f0e227e49ccc7f06ff750b9eb28ca8b064de +"@typescript-eslint/types@npm:4.29.1": + version: 4.29.1 + resolution: "@typescript-eslint/types@npm:4.29.1" + checksum: feb40f23db3c20d7fe9e629a44ef54d7225e064df6435e31f8417a9a1f5c216f92782a7c03a5fc9808b313a93a2f91df324d01a2e58478c57c5bbb0eb1f519a9 languageName: node linkType: hard @@ -7956,12 +8005,12 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:4.29.2": - version: 4.29.2 - resolution: "@typescript-eslint/typescript-estree@npm:4.29.2" +"@typescript-eslint/typescript-estree@npm:4.29.1": + version: 4.29.1 + resolution: "@typescript-eslint/typescript-estree@npm:4.29.1" dependencies: - "@typescript-eslint/types": 4.29.2 - "@typescript-eslint/visitor-keys": 4.29.2 + "@typescript-eslint/types": 4.29.1 + "@typescript-eslint/visitor-keys": 4.29.1 debug: ^4.3.1 globby: ^11.0.3 is-glob: ^4.0.1 @@ -7970,17 +8019,17 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 90342d27f3f0837ad39f9b7e7d7c3c0b6de9c5b0770f5a18d490ebaf7be78efa65ba46ce0ca3004ad946ca1adc5865c5d3ba3b049c95b3b193bfdf0eb5e23095 + checksum: 2721bb06f920e6efa710e689eefc20729a882ded71de1c7e81f866fb0ceda00b2e7f4e742842bd79c94985390f98180588e3b7bfb6b50c4844fb857ae18eaef3 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:4.29.2": - version: 4.29.2 - resolution: "@typescript-eslint/visitor-keys@npm:4.29.2" +"@typescript-eslint/visitor-keys@npm:4.29.1": + version: 4.29.1 + resolution: "@typescript-eslint/visitor-keys@npm:4.29.1" dependencies: - "@typescript-eslint/types": 4.29.2 + "@typescript-eslint/types": 4.29.1 eslint-visitor-keys: ^2.0.0 - checksum: 34185d8c6466340aba746d69b36d357da2d06577d73f58358648c142bd0f181d7fae01ca1138188a665ef074ea7e1bc6306ef9d50f29914c8bcea4e9ea1f82f2 + checksum: a99db94f80331a0bdd2e4828d43daa7d391312d6edf77d8f80fcd37530ea9eacbf8014dc3378a26e9e22891e0d97c5637fcde2d6272ed091de74ede22bec0224 languageName: node linkType: hard @@ -8603,6 +8652,44 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-form-builder-so-ddb-es@^5.15.0, @webiny/api-form-builder-so-ddb-es@workspace:packages/api-form-builder-so-ddb-es": + version: 0.0.0-use.local + resolution: "@webiny/api-form-builder-so-ddb-es@workspace:packages/api-form-builder-so-ddb-es" + dependencies: + "@babel/cli": ^7.5.5 + "@babel/core": ^7.5.5 + "@babel/preset-env": ^7.5.5 + "@babel/preset-typescript": ^7.8.3 + "@babel/runtime": ^7.5.5 + "@elastic/elasticsearch": 7.12.0 + "@shelf/jest-elasticsearch": ^1.0.0 + "@webiny/api-dynamodb-to-elasticsearch": ^5.15.0 + "@webiny/api-elasticsearch": ^5.15.0 + "@webiny/api-form-builder": ^5.15.0 + "@webiny/api-i18n": ^5.15.0 + "@webiny/api-tenancy": ^5.15.0 + "@webiny/api-upgrade": ^5.15.0 + "@webiny/cli": ^5.15.0 + "@webiny/db-dynamodb": ^5.15.0 + "@webiny/error": ^5.15.0 + "@webiny/handler": ^5.15.0 + "@webiny/handler-aws": ^5.15.0 + "@webiny/handler-db": ^5.15.0 + "@webiny/plugins": ^5.15.0 + "@webiny/project-utils": ^5.15.0 + "@webiny/utils": ^5.15.0 + csvtojson: ^2.0.10 + dynamodb-toolbox: ^0.3.4 + elastic-ts: ^0.7.0 + jest: ^26.6.3 + jest-dynalite: ^3.2.0 + jest-environment-node: ^27.2.4 + rimraf: ^3.0.2 + ttypescript: ^1.5.12 + typescript: ^4.1.3 + languageName: unknown + linkType: soft + "@webiny/api-form-builder@^5.15.0, @webiny/api-form-builder@workspace:packages/api-form-builder": version: 0.0.0-use.local resolution: "@webiny/api-form-builder@workspace:packages/api-form-builder" @@ -8626,14 +8713,14 @@ __metadata: "@webiny/api-tenancy": ^5.15.0 "@webiny/api-upgrade": ^5.15.0 "@webiny/cli": ^5.15.0 - "@webiny/db-dynamodb": ^5.15.0 "@webiny/error": ^5.15.0 "@webiny/handler": ^5.15.0 "@webiny/handler-aws": ^5.15.0 - "@webiny/handler-db": ^5.15.0 "@webiny/handler-graphql": ^5.15.0 "@webiny/plugins": ^5.15.0 "@webiny/project-utils": ^5.15.0 + "@webiny/pubsub": ^5.15.0 + "@webiny/utils": ^5.15.0 "@webiny/validation": ^5.15.0 commodo-fields-object: ^1.0.6 csvtojson: ^2.0.10 @@ -8646,6 +8733,7 @@ __metadata: node-fetch: ^2.6.1 rimraf: ^3.0.2 slugify: ^1.2.9 + ttypescript: ^1.5.12 typescript: ^4.1.3 languageName: unknown linkType: soft @@ -10682,6 +10770,9 @@ __metadata: dependencies: "@babel/cli": ^7.12.10 "@babel/core": ^7.5.5 + "@babel/preset-env": ^7.5.5 + "@babel/preset-typescript": ^7.8.3 + "@babel/runtime": ^7.5.5 "@webiny/cli": ^5.13.0 "@webiny/project-utils": ^5.13.0 rimraf: ^3.0.2 @@ -10915,6 +11006,23 @@ __metadata: languageName: unknown linkType: soft +"@webiny/utils@^5.15.0, @webiny/utils@workspace:packages/utils": + version: 0.0.0-use.local + resolution: "@webiny/utils@workspace:packages/utils" + dependencies: + "@babel/cli": ^7.12.10 + "@babel/core": ^7.5.5 + "@babel/preset-env": ^7.5.5 + "@babel/preset-typescript": ^7.8.3 + "@babel/runtime": ^7.5.5 + "@webiny/cli": ^5.13.0 + "@webiny/project-utils": ^5.13.0 + rimraf: ^3.0.2 + ttypescript: ^1.5.12 + typescript: ^4.1.3 + languageName: unknown + linkType: soft + "@webiny/validation@^5.15.0, @webiny/validation@workspace:packages/validation": version: 0.0.0-use.local resolution: "@webiny/validation@workspace:packages/validation" @@ -11413,16 +11521,16 @@ __metadata: languageName: node linkType: hard -"amazon-cognito-identity-js@npm:5.1.0": - version: 5.1.0 - resolution: "amazon-cognito-identity-js@npm:5.1.0" +"amazon-cognito-identity-js@npm:5.0.6": + version: 5.0.6 + resolution: "amazon-cognito-identity-js@npm:5.0.6" dependencies: buffer: 4.9.2 crypto-js: ^4.1.1 fast-base64-decode: ^1.0.0 isomorphic-unfetch: ^3.0.0 js-cookie: ^2.2.1 - checksum: b66f353573743097440f1b788205e3e04c724720f8f940a5d109df38ef4e23aeba341674de81ea121ef2c7b0c3192fe8439bbb99a4356a10ae06f935b91d3019 + checksum: 9b38136b3f10c4c0fdccdba17cb395401ae9dbe3244dcfa27ef5a7a7f5527592b60626b7ca13cc1cc03ce3959c0650927d4981bcda43b252c027582401fd6a3f languageName: node linkType: hard @@ -11717,6 +11825,7 @@ __metadata: "@webiny/api-file-manager-ddb-es": ^5.15.0 "@webiny/api-file-manager-s3": ^5.15.0 "@webiny/api-form-builder": ^5.15.0 + "@webiny/api-form-builder-so-ddb-es": ^5.15.0 "@webiny/api-headless-cms": ^5.15.0 "@webiny/api-headless-cms-ddb-es": ^5.15.0 "@webiny/api-i18n": ^5.15.0 @@ -12500,8 +12609,8 @@ __metadata: linkType: hard "aws-sdk@npm:^2.0.0, aws-sdk@npm:^2.539.0": - version: 2.972.0 - resolution: "aws-sdk@npm:2.972.0" + version: 2.968.0 + resolution: "aws-sdk@npm:2.968.0" dependencies: buffer: 4.9.2 events: 1.1.1 @@ -12512,7 +12621,7 @@ __metadata: url: 0.10.3 uuid: 3.3.2 xml2js: 0.4.19 - checksum: c051bd1ee0e608c9eea3d9b38ea0ef761fbb605c52791c00cb8db90689b168a4868104176c4efa09658e3466d1f398a7bfaeaa0362c9d8c787c8f71a6d9f74bd + checksum: 81975ec5461da7de36195253b0221612b1ce5fe80e555efcddd5e803d80b8e6987a9af8a0dd56769b23098a46f6080e1e7aaec35de894e0f227a34bff42f2261 languageName: node linkType: hard @@ -13403,9 +13512,9 @@ __metadata: linkType: hard "boolean@npm:^3.0.1, boolean@npm:^3.0.2": - version: 3.1.4 - resolution: "boolean@npm:3.1.4" - checksum: 9a48bce4799ccca861be0ec9564f47a96dd01535079624e37b06df45e5dc89d14dcefa04c56f1491a91f0827029f6d9e25690f0b308dfc248b9e64e15593aa35 + version: 3.1.2 + resolution: "boolean@npm:3.1.2" + checksum: 300de18a48c9226e90bdb29103a5f1ce40265dc8720767fec27fe14284ed9eb51d983dcfa44f1487f8e3bdf6f6b6ba62ac5a4faa6ceceba28ca449aa2723a2ac languageName: node linkType: hard @@ -13632,17 +13741,17 @@ __metadata: linkType: hard "browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.16.6, browserslist@npm:^4.16.7, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4, browserslist@npm:^4.9.1": - version: 4.16.8 - resolution: "browserslist@npm:4.16.8" + version: 4.16.7 + resolution: "browserslist@npm:4.16.7" dependencies: - caniuse-lite: ^1.0.30001251 - colorette: ^1.3.0 - electron-to-chromium: ^1.3.811 + caniuse-lite: ^1.0.30001248 + colorette: ^1.2.2 + electron-to-chromium: ^1.3.793 escalade: ^3.1.1 - node-releases: ^1.1.75 + node-releases: ^1.1.73 bin: browserslist: cli.js - checksum: a442ab2156b95bc88627591c5af6f3e4952eab4a3b1eef942af37bbeaa717f60a78b31890c76b1ade08e881c541c6ac9e7a74f0a66968658e9fe013e69e69093 + checksum: 0db38f58cd84c15edd45330a57156bda5899c335d71ff52e17c395ad274ae60a1c3e4c10ab3615cef1fe043d136f126699ee5deef647f89df3a87711cc193480 languageName: node linkType: hard @@ -14176,7 +14285,7 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30000989, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001251": +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30000989, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001248": version: 1.0.30001251 resolution: "caniuse-lite@npm:1.0.30001251" checksum: 918e1b1662c26c11291206146bc305d7fd1ca351aa9231c2e21cb1526d87b444830e2d8dc54416ebb8ecf7e0addea12d66a1c41493476229987e5e6922f0c14b @@ -14873,7 +14982,7 @@ __metadata: languageName: node linkType: hard -"colorette@npm:^1.2.1, colorette@npm:^1.3.0": +"colorette@npm:^1.2.1, colorette@npm:^1.2.2": version: 1.3.0 resolution: "colorette@npm:1.3.0" checksum: bda403dfba4d032bee4169f2a6436a83ae3da488a53bcb3be92dc44ace056518245cc614b12429d7529493d6b090a119b2523b0d55e8cd6b81ad939a3003c008 @@ -15492,19 +15601,19 @@ __metadata: linkType: hard "core-js-compat@npm:^3.14.0, core-js-compat@npm:^3.16.0, core-js-compat@npm:^3.6.2, core-js-compat@npm:^3.8.0": - version: 3.16.2 - resolution: "core-js-compat@npm:3.16.2" + version: 3.16.1 + resolution: "core-js-compat@npm:3.16.1" dependencies: browserslist: ^4.16.7 semver: 7.0.0 - checksum: 48fc6c5f2a389e58855f7f0c77623878e78b95dad268ec25cbc38fbc98ee60d3241f4b653543252427b26e378a8cbf5e9ae323e16afe00c29c3c47b3a6a7b655 + checksum: fbbc054f6d1cc0e172846b39b264c7c9ef5405390a6d5e1ff7bda7c71457932e112fcf861e1c6171505a2e407407db32b99cd24badcc79a5d08fd04e46076c4d languageName: node linkType: hard "core-js-pure@npm:^3.0.1, core-js-pure@npm:^3.16.0": - version: 3.16.2 - resolution: "core-js-pure@npm:3.16.2" - checksum: e44acd945b78f6faf96f38059a5347eb236eaae9b2fa897e58f52b12b64e5233c2b1243e953b828ddec62c215c5f911ac2d3c9276974ded1c04331961d877869 + version: 3.16.1 + resolution: "core-js-pure@npm:3.16.1" + checksum: efdec39af1d0b807a355c7ae42229433a9f8fda053251f91e117eb82d380a5cc56f535ed80fa2a37a941d88c692d12bf3be1309c28ac6ba6f0a6e78576525666 languageName: node linkType: hard @@ -15523,9 +15632,9 @@ __metadata: linkType: hard "core-js@npm:^3.0.1, core-js@npm:^3.0.4, core-js@npm:^3.6.1": - version: 3.16.2 - resolution: "core-js@npm:3.16.2" - checksum: f48b988ab6d144aad1a7c09a73174946f797ec050b4566dee9bf0227560296cdd0f714b37f0ee065303eccf0e7fb8115ccca52ab3e80db12e0dcc20a88219483 + version: 3.16.1 + resolution: "core-js@npm:3.16.1" + checksum: 7924fa2a7f00e3a33bbe92fe42ba59fbbe7e01557a59824d000976300be3ba224cca20161f61654b3861ea80d244d6adfee6ed3ae6d6d9931210a37510fb7c9b languageName: node linkType: hard @@ -17628,10 +17737,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.3.247, electron-to-chromium@npm:^1.3.378, electron-to-chromium@npm:^1.3.811": - version: 1.3.813 - resolution: "electron-to-chromium@npm:1.3.813" - checksum: 48d613a43b75543d6f0a697ba4a3cddba456b3949d59759a3c91ea8ed4a860e3b77f6a035862b9fa10f60abb5cc4a3e769ef58bbd95951186dc9cf09bb6940c1 +"electron-to-chromium@npm:^1.3.247, electron-to-chromium@npm:^1.3.378, electron-to-chromium@npm:^1.3.793": + version: 1.3.806 + resolution: "electron-to-chromium@npm:1.3.806" + checksum: cba7104935f8fe9d5d466a7ade478021a7ede8d8c8ad5a13bf11022ebbc316270065bb2ed7ecd3fb450bbf1351628d123d4efadd600001f97c05e6628f0de59d languageName: node linkType: hard @@ -18138,13 +18247,13 @@ __metadata: languageName: node linkType: hard -"eslint-import-resolver-node@npm:^0.3.6": - version: 0.3.6 - resolution: "eslint-import-resolver-node@npm:0.3.6" +"eslint-import-resolver-node@npm:^0.3.5": + version: 0.3.5 + resolution: "eslint-import-resolver-node@npm:0.3.5" dependencies: debug: ^3.2.7 resolve: ^1.20.0 - checksum: 6266733af1e112970e855a5bcc2d2058fb5ae16ad2a6d400705a86b29552b36131ffc5581b744c23d550de844206fb55e9193691619ee4dbf225c4bde526b1c8 + checksum: 93a8176205f18c40d2c11c444fab89aa3990c5a5eed226ef03a893b5779e7cd4d1f5f52b2bbbbbe4b13fb2a75ef629278be0b52099480cbe6e7024888d9982dd languageName: node linkType: hard @@ -18187,27 +18296,27 @@ __metadata: linkType: hard "eslint-plugin-import@npm:^2.22.1": - version: 2.24.1 - resolution: "eslint-plugin-import@npm:2.24.1" + version: 2.24.0 + resolution: "eslint-plugin-import@npm:2.24.0" dependencies: array-includes: ^3.1.3 array.prototype.flat: ^1.2.4 debug: ^2.6.9 doctrine: ^2.1.0 - eslint-import-resolver-node: ^0.3.6 + eslint-import-resolver-node: ^0.3.5 eslint-module-utils: ^2.6.2 find-up: ^2.0.0 has: ^1.0.3 - is-core-module: ^2.6.0 + is-core-module: ^2.4.0 minimatch: ^3.0.4 - object.values: ^1.1.4 + object.values: ^1.1.3 pkg-up: ^2.0.0 read-pkg-up: ^3.0.0 resolve: ^1.20.0 - tsconfig-paths: ^3.10.1 + tsconfig-paths: ^3.9.0 peerDependencies: eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 - checksum: 6a43dc2c045d8f70a370fead2ed342a4b009685bb75818c0e4dab8bb118b853a77d532c63684f5c0b808239809a5a7289c968e45b5cc56960963be5b8cf1ee54 + checksum: 79fb1094197cd1dc720725bd29e5c5fe7d123fd9dd31eb182849993a81a8c18e2bfbc4d267a2caabe02bd4d21aafb1eca1da2f55aca7e5df99fd8ba908e7b869 languageName: node linkType: hard @@ -19092,11 +19201,11 @@ __metadata: linkType: hard "fastq@npm:^1.6.0": - version: 1.12.0 - resolution: "fastq@npm:1.12.0" + version: 1.11.1 + resolution: "fastq@npm:1.11.1" dependencies: reusify: ^1.0.4 - checksum: 486db511686b5ab28b1d87170f05c3fa6c8d769cde6861ed34cf3756cdf356950ba9c7dde0bc976ad4308b85aa9ef6214c685887f9f724be72c054a7becb642a + checksum: 3877a63bee4f63af9277d6169a766804c9e1c7829a070b6843c5b799aa72177e71465427889c96510e5608c334dd3c912ab0b3ca70c1c8c4c1b03449d9f2c5ba languageName: node linkType: hard @@ -19670,12 +19779,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.10.0": - version: 1.14.2 - resolution: "follow-redirects@npm:1.14.2" + version: 1.14.1 + resolution: "follow-redirects@npm:1.14.1" peerDependenciesMeta: debug: optional: true - checksum: 53195df4a2f36202177f40a2d59d497d630f3b20e2e51e2b697ee5f9c0a5261985f164b23d3744a574e117618af668d07c0d000c2c5cb0c1546851671585ac4e + checksum: 7381a55bdc6951c5c1ab73a8da99d9fa4c0496ce72dba92cd2ac2babe0e3ebde9b81c5bca889498ad95984bc773d713284ca2bb17f1b1e1416e5f6531e39a488 languageName: node linkType: hard @@ -20234,16 +20343,6 @@ fsevents@^1.2.7: languageName: node linkType: hard -"get-symbol-description@npm:^1.0.0": - version: 1.0.0 - resolution: "get-symbol-description@npm:1.0.0" - dependencies: - call-bind: ^1.0.2 - get-intrinsic: ^1.1.1 - checksum: 9ceff8fe968f9270a37a1f73bf3f1f7bda69ca80f4f80850670e0e7b9444ff99323f7ac52f96567f8b5f5fbe7ac717a0d81d3407c7313e82810c6199446a5247 - languageName: node - linkType: hard - "get-uri@npm:3": version: 3.0.2 resolution: "get-uri@npm:3.0.2" @@ -22334,12 +22433,12 @@ fsevents@^1.2.7: languageName: node linkType: hard -"is-core-module@npm:^2.2.0, is-core-module@npm:^2.5.0, is-core-module@npm:^2.6.0": - version: 2.6.0 - resolution: "is-core-module@npm:2.6.0" +"is-core-module@npm:^2.2.0, is-core-module@npm:^2.4.0": + version: 2.5.0 + resolution: "is-core-module@npm:2.5.0" dependencies: has: ^1.0.3 - checksum: 183b3b96fed19822b13959876b0317e61fc2cb5ebcbc21639904c81f7ae328af57f8e18cc4750a9c4abebd686130c70d34a89521e57dbe002edfa4614507ce18 + checksum: e007de6ca5c391f8a669b9335192967d8815f9119f97d81fc4cde07febe09143263bc0146e86e813120223ea9a034cf0608d15b53b0269e19b4dc0a220ce0b4f languageName: node linkType: hard @@ -23319,6 +23418,20 @@ fsevents@^1.2.7: languageName: node linkType: hard +"jest-environment-node@npm:^27.2.4": + version: 27.2.4 + resolution: "jest-environment-node@npm:27.2.4" + dependencies: + "@jest/environment": ^27.2.4 + "@jest/fake-timers": ^27.2.4 + "@jest/types": ^27.2.4 + "@types/node": "*" + jest-mock: ^27.2.4 + jest-util: ^27.2.4 + checksum: b131068b9cb58c982f70409284810cb9f5e7c0d4381fa08b041ea852f925c09030e2ec05e45b5950253ea49844427887e2292a388f3e19a58dee877f85afb7e8 + languageName: node + linkType: hard + "jest-extended@npm:^0.11.5": version: 0.11.5 resolution: "jest-extended@npm:0.11.5" @@ -23497,6 +23610,23 @@ fsevents@^1.2.7: languageName: node linkType: hard +"jest-message-util@npm:^27.2.4": + version: 27.2.4 + resolution: "jest-message-util@npm:27.2.4" + dependencies: + "@babel/code-frame": ^7.12.13 + "@jest/types": ^27.2.4 + "@types/stack-utils": ^2.0.0 + chalk: ^4.0.0 + graceful-fs: ^4.2.4 + micromatch: ^4.0.4 + pretty-format: ^27.2.4 + slash: ^3.0.0 + stack-utils: ^2.0.3 + checksum: 61c43fdc8f7b1ecdffa311d9f7f9ad34e9e74abd4484ac7188ff0717c1f2bf05816bb302e4c454562ce1065ebe8583891d1f6e58a0b9cbc8fe04eb625fad5aea + languageName: node + linkType: hard + "jest-mock-console@npm:^1.0.0": version: 1.1.0 resolution: "jest-mock-console@npm:1.1.0" @@ -23526,6 +23656,16 @@ fsevents@^1.2.7: languageName: node linkType: hard +"jest-mock@npm:^27.2.4": + version: 27.2.4 + resolution: "jest-mock@npm:27.2.4" + dependencies: + "@jest/types": ^27.2.4 + "@types/node": "*" + checksum: 779507837588a725b716379a78902ed39f41c39bcdcc828c2f2666388f5411ec8dce362b1b25d85e7ea376c8b12c74550bf9f048b226274a7bcd245af6b3473c + languageName: node + linkType: hard + "jest-pnp-resolver@npm:^1.2.2": version: 1.2.2 resolution: "jest-pnp-resolver@npm:1.2.2" @@ -23706,6 +23846,20 @@ fsevents@^1.2.7: languageName: node linkType: hard +"jest-util@npm:^27.2.4": + version: 27.2.4 + resolution: "jest-util@npm:27.2.4" + dependencies: + "@jest/types": ^27.2.4 + "@types/node": "*" + chalk: ^4.0.0 + graceful-fs: ^4.2.4 + is-ci: ^3.0.0 + picomatch: ^2.2.3 + checksum: 319f583a3279768b017e5462d647271191034fccdf84bacdc6a54f1750bf21f1fa16bd960515883959b0a1a0cb7346fdc627448c5a3bd8f00100dcb6f3aa8305 + languageName: node + linkType: hard + "jest-validate@npm:^23.5.0": version: 23.6.0 resolution: "jest-validate@npm:23.6.0" @@ -26820,10 +26974,10 @@ fsevents@^1.2.7: languageName: node linkType: hard -"node-releases@npm:^1.1.29, node-releases@npm:^1.1.52, node-releases@npm:^1.1.75": - version: 1.1.75 - resolution: "node-releases@npm:1.1.75" - checksum: 74028e7d193c9c5986b2f6bb51f4f6405a3f144599bbb19751c81faece52af8eb3f5abac40cbcd11ead44be3f856be125aa71fbb8dd8bf0c7f90caa94179ee51 +"node-releases@npm:^1.1.29, node-releases@npm:^1.1.52, node-releases@npm:^1.1.73": + version: 1.1.74 + resolution: "node-releases@npm:1.1.74" + checksum: 3dde058c30f34bda66e11821a3d6a110deb5dd3abe8b5113cf88d88344f02c7a3b4599e92fd6b1f67fff4df6c70edc23543d3138033c0f32514401438d58a933 languageName: node linkType: hard @@ -26892,14 +27046,14 @@ fsevents@^1.2.7: linkType: hard "normalize-package-data@npm:^3.0.0": - version: 3.0.3 - resolution: "normalize-package-data@npm:3.0.3" + version: 3.0.2 + resolution: "normalize-package-data@npm:3.0.2" dependencies: hosted-git-info: ^4.0.1 - is-core-module: ^2.5.0 + resolve: ^1.20.0 semver: ^7.3.4 validate-npm-package-license: ^3.0.1 - checksum: bbcee00339e7c26fdbc760f9b66d429258e2ceca41a5df41f5df06cc7652de8d82e8679ff188ca095cad8eff2b6118d7d866af2b68400f74602fbcbce39c160a + checksum: b50e26f2c81c51ddf6b5a04f731ddc2fc409ef114d44b5e2e4a7cfaa2d45cb86f76fea0c3a57a41e106f71c777124f93b4a75fe1c4b3aa4844971a30a30d94c9 languageName: node linkType: hard @@ -27333,7 +27487,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"object.values@npm:^1.1.0, object.values@npm:^1.1.4": +"object.values@npm:^1.1.0, object.values@npm:^1.1.3, object.values@npm:^1.1.4": version: 1.1.4 resolution: "object.values@npm:1.1.4" dependencies: @@ -32471,13 +32625,13 @@ resolve@^2.0.0-next.3: linkType: hard "sass@npm:^1.38.0": - version: 1.38.0 - resolution: "sass@npm:1.38.0" + version: 1.42.1 + resolution: "sass@npm:1.42.1" dependencies: chokidar: ">=3.0.0 <4.0.0" bin: sass: sass.js - checksum: 2a7140084785ab86a7577b8b1568a2fe9091716e02eacb071e37b32d3b5947d4b433ffc0361494ddeefcd0d8b2d44a87ee43b23630995250a7160261b1307e43 + checksum: 467817475b23a3da3aac2f5e401f3b3d9431845b31ff1bc6269d4677852b4e1445e50d6ed1a8daffa4a76398cff6c531670171823466137ed8c2721c17983973 languageName: node linkType: hard @@ -34113,14 +34267,14 @@ resolve@^2.0.0-next.3: linkType: hard "symbol.prototype.description@npm:^1.0.0": - version: 1.0.5 - resolution: "symbol.prototype.description@npm:1.0.5" + version: 1.0.4 + resolution: "symbol.prototype.description@npm:1.0.4" dependencies: call-bind: ^1.0.2 - get-symbol-description: ^1.0.0 - has-symbols: ^1.0.2 + es-abstract: ^1.18.0-next.2 + has-symbols: ^1.0.1 object.getownpropertydescriptors: ^2.1.2 - checksum: 2bf20a5fbc74bdda7133e0915b978bf50bf5e2a48dd2174885ba6cd623d001ca18f7dbb1e01a3f3ea3a34f05030175ebee3dcb357f099a61af6e964f3281e9b9 + checksum: 12e71b55af8d6020ce5a3758bfd6044ea877951b92af02302d5f40438490f36f4b4836d0d8bddc032af63e8e7ec2d25938b93ff759b31a25b39303e1dfb74d44 languageName: node linkType: hard @@ -34217,8 +34371,8 @@ resolve@^2.0.0-next.3: linkType: hard "tar@npm:^4.4.10, tar@npm:^4.4.12, tar@npm:^4.4.8": - version: 4.4.19 - resolution: "tar@npm:4.4.19" + version: 4.4.17 + resolution: "tar@npm:4.4.17" dependencies: chownr: ^1.1.4 fs-minipass: ^1.2.7 @@ -34227,13 +34381,13 @@ resolve@^2.0.0-next.3: mkdirp: ^0.5.5 safe-buffer: ^5.2.1 yallist: ^3.1.1 - checksum: 423c8259b17f8f612cef9c96805d65f90ba9a28e19be582cd9d0fcb217038219f29b7547198e8fd617da5f436376d6a74b99827acd1238d2f49cf62330f9664e + checksum: e3908e428759e156e19459cc886d4ec80e26335a13d79e6ec3e65e3fe22b135e788585478da480472029af6b0d6c8295d03d817878537575673cffe4de9626b1 languageName: node linkType: hard "tar@npm:^6.0.2, tar@npm:^6.0.5, tar@npm:^6.1.0": - version: 6.1.10 - resolution: "tar@npm:6.1.10" + version: 6.1.8 + resolution: "tar@npm:6.1.8" dependencies: chownr: ^2.0.0 fs-minipass: ^2.0.0 @@ -34241,7 +34395,7 @@ resolve@^2.0.0-next.3: minizlib: ^2.1.1 mkdirp: ^1.0.3 yallist: ^4.0.0 - checksum: 9e7ba4abc81a3095a5fc7a3e97a9f753103f5ce884c52431613f281954ec83d690e58f1ccad3cdd9eacdea901431f96f5f879dddc421f6b231994f6e7de07e1f + checksum: f5aa41340d3415ef6f19ed0ee620db1f7cb9ea3f5ea7bfef5ea199bdb39e978d11f31d347231193e0d9262f81de3e358aa3dda6ed0c1909f22a8ce3e3a743dad languageName: node linkType: hard @@ -34939,7 +35093,7 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"tsconfig-paths@npm:^3.10.1": +"tsconfig-paths@npm:^3.9.0": version: 3.10.1 resolution: "tsconfig-paths@npm:3.10.1" dependencies: @@ -35315,9 +35469,9 @@ resolve@^2.0.0-next.3: linkType: hard "uniqid@npm:^5.0.3, uniqid@npm:^5.2.0, uniqid@npm:^5.3.0": - version: 5.4.0 - resolution: "uniqid@npm:5.4.0" - checksum: 69fc28e7b2b5b24227b4295e51aa7c1d4085a60655ad071db8fc350876cc9044d68ee0781c0d27c2c98691380aa418970e0d1b02a1e4564480f9d19b0dc1707b + version: 5.3.0 + resolution: "uniqid@npm:5.3.0" + checksum: 74a14b924ce78374ae29388bed7fe81895fb64866a9c17cdc4d0854ce620ba35ffc0e719f89d985f1f510cbde502293214c99de825450ee8ce4719a736c397cf languageName: node linkType: hard