From 5075f5c5d875916a83cb57dcef5ac61569279849 Mon Sep 17 00:00:00 2001 From: Linden Crandall Date: Thu, 6 Feb 2025 04:14:18 +0900 Subject: [PATCH] commit upstream files --- .devcontainer/Dockerfile | 1 + .devcontainer/boot.sh | 58 + .devcontainer/devcontainer.json | 53 + .devcontainer/docker-compose.yml | 63 + .dockerignore | 12 + .github/workflows/backend.yml | 51 + .github/workflows/ci.yml | 93 + .github/workflows/docs-change.yml | 32 + .github/workflows/playwright.yml | 145 + .gitignore | 129 + .node-version | 1 + .nvmrc | 1 + .prettierrc.js | 3 + .vscode/launch.json | 12 + .vscode/settings.json | 7 + .yarnrc | 1 + CODE_OF_CONDUCT.md | 76 + CONTRIBUTOR_LICENSE_AGREEMENT.md | 5 + LICENSE | 3 + LICENSE.agpl | 661 + LICENSE.enterprise | 35 + docker-compose.yml | 70 + docker/Dockerfile | 27 + docker/Dockerfile.compose | 11 + docker/compose-entrypoint.sh | 26 + docker/entrypoint.sh | 13 + packages/backend/.env-example | 22 + packages/backend/.env-example.test | 15 + packages/backend/.eslintignore | 14 + packages/backend/.eslintrc.json | 12 + packages/backend/README.md | 4 + packages/backend/bin/database/client.js | 9 + .../bin/database/convert-migrations.js | 31 + packages/backend/bin/database/create.js | 3 + packages/backend/bin/database/drop.js | 3 + packages/backend/bin/database/seed-user.js | 3 + packages/backend/bin/database/utils.js | 145 + packages/backend/knexfile.js | 33 + packages/backend/package.json | 116 + packages/backend/src/app.js | 71 + .../airtable/actions/create-record/index.js | 92 + .../airtable/actions/find-record/index.js | 174 + .../src/apps/airtable/actions/index.js | 4 + .../src/apps/airtable/assets/favicon.svg | 9 + .../apps/airtable/auth/generate-auth-url.js | 38 + .../backend/src/apps/airtable/auth/index.js | 48 + .../apps/airtable/auth/is-still-verified.js | 8 + .../src/apps/airtable/auth/refresh-token.js | 40 + .../apps/airtable/auth/verify-credentials.js | 56 + .../apps/airtable/common/add-auth-header.js | 12 + .../src/apps/airtable/common/auth-scope.js | 12 + .../apps/airtable/common/get-current-user.js | 6 + .../src/apps/airtable/dynamic-data/index.js | 6 + .../airtable/dynamic-data/list-bases/index.js | 28 + .../dynamic-data/list-table-fields/index.js | 39 + .../dynamic-data/list-table-views/index.js | 39 + .../dynamic-data/list-tables/index.js | 35 + .../src/apps/airtable/dynamic-fields/index.js | 3 + .../dynamic-fields/list-fields/index.js | 86 + packages/backend/src/apps/airtable/index.js | 22 + .../src/apps/anthropic/actions/index.js | 3 + .../anthropic/actions/send-message/index.js | 124 + .../src/apps/anthropic/assets/favicon.svg | 8 + .../backend/src/apps/anthropic/auth/index.js | 34 + .../apps/anthropic/auth/is-still-verified.js | 7 + .../apps/anthropic/auth/verify-credentials.js | 5 + .../common/add-anthropic-version-header.js | 7 + .../apps/anthropic/common/add-auth-header.js | 9 + .../src/apps/anthropic/dynamic-data/index.js | 3 + .../dynamic-data/list-models/index.js | 31 + packages/backend/src/apps/anthropic/index.js | 21 + .../src/apps/appwrite/assets/favicon.svg | 1 + .../backend/src/apps/appwrite/auth/index.js | 65 + .../apps/appwrite/auth/is-still-verified.js | 8 + .../apps/appwrite/auth/verify-credentials.js | 5 + .../apps/appwrite/common/add-auth-header.js | 16 + .../src/apps/appwrite/common/set-base-url.js | 13 + .../src/apps/appwrite/dynamic-data/index.js | 4 + .../dynamic-data/list-collections/index.js | 44 + .../dynamic-data/list-databases/index.js | 36 + packages/backend/src/apps/appwrite/index.js | 21 + .../src/apps/appwrite/triggers/index.js | 3 + .../appwrite/triggers/new-documents/index.js | 104 + .../src/apps/azure-openai/actions/index.js | 3 + .../azure-openai/actions/send-prompt/index.js | 97 + .../src/apps/azure-openai/assets/favicon.svg | 6 + .../src/apps/azure-openai/auth/index.js | 58 + .../azure-openai/auth/is-still-verified.js | 6 + .../azure-openai/auth/verify-credentials.js | 5 + .../azure-openai/common/add-auth-header.js | 13 + .../apps/azure-openai/common/set-base-url.js | 11 + .../backend/src/apps/azure-openai/index.js | 20 + .../src/apps/brave-search/actions/index.js | 3 + .../brave-search/actions/web-search/index.js | 52 + .../src/apps/brave-search/assets/favicon.svg | 5 + .../src/apps/brave-search/auth/index.js | 34 + .../brave-search/auth/is-still-verified.js | 5 + .../brave-search/auth/verify-credentials.js | 5 + .../brave-search/common/add-accept-header.js | 7 + .../brave-search/common/add-auth-header.js | 9 + .../apps/brave-search/dynamic-data/index.js | 3 + .../dynamic-data/list-models/index.js | 31 + .../backend/src/apps/brave-search/index.js | 21 + .../carbone/actions/add-template/index.js | 35 + .../backend/src/apps/carbone/actions/index.js | 3 + .../src/apps/carbone/assets/favicon.svg | 444 + .../backend/src/apps/carbone/auth/index.js | 33 + .../apps/carbone/auth/is-still-verified.js | 8 + .../apps/carbone/auth/verify-credentials.js | 10 + .../apps/carbone/common/add-auth-header.js | 10 + packages/backend/src/apps/carbone/index.js | 18 + .../clickup/actions/create-folder/index.js | 72 + .../apps/clickup/actions/create-list/index.js | 135 + .../apps/clickup/actions/create-task/index.js | 294 + .../clickup/actions/find-task-by-id/index.js | 82 + .../backend/src/apps/clickup/actions/index.js | 6 + .../src/apps/clickup/assets/favicon.svg | 27 + .../apps/clickup/auth/generate-auth-url.js | 21 + .../backend/src/apps/clickup/auth/index.js | 46 + .../apps/clickup/auth/is-still-verified.js | 8 + .../apps/clickup/auth/verify-credentials.js | 31 + .../apps/clickup/common/add-auth-header.js | 9 + .../apps/clickup/common/get-current-user.js | 6 + .../src/apps/clickup/dynamic-data/index.js | 19 + .../dynamic-data/list-assignees/index.js | 28 + .../dynamic-data/list-folders/index.js | 28 + .../clickup/dynamic-data/list-lists/index.js | 28 + .../clickup/dynamic-data/list-spaces/index.js | 28 + .../dynamic-data/list-statuses/index.js | 28 + .../clickup/dynamic-data/list-tags/index.js | 28 + .../clickup/dynamic-data/list-tasks/index.js | 41 + .../dynamic-data/list-workspaces/index.js | 23 + .../src/apps/clickup/dynamic-fields/index.js | 3 + .../dynamic-fields/use-custom-id/index.js | 29 + packages/backend/src/apps/clickup/index.js | 24 + .../src/apps/clickup/triggers/index.js | 6 + .../clickup/triggers/new-folders/index.js | 105 + .../apps/clickup/triggers/new-lists/index.js | 129 + .../apps/clickup/triggers/new-tasks/index.js | 186 + .../clickup/triggers/updated-task/index.js | 172 + .../backend/src/apps/code/actions/index.js | 3 + .../apps/code/actions/run-javascript/index.js | 84 + .../backend/src/apps/code/assets/favicon.svg | 5 + packages/backend/src/apps/code/index.js | 14 + .../cryptography/actions/create-hmac/index.js | 64 + .../create-rsa-sha256-signature/index.js | 65 + .../src/apps/cryptography/actions/index.js | 4 + .../src/apps/cryptography/assets/favicon.svg | 3 + .../backend/src/apps/cryptography/index.js | 14 + .../apps/datastore/actions/get-value/index.js | 27 + .../src/apps/datastore/actions/index.js | 4 + .../apps/datastore/actions/set-value/index.js | 36 + .../src/apps/datastore/assets/favicon.svg | 13 + packages/backend/src/apps/datastore/index.js | 14 + .../backend/src/apps/deepl/actions/index.js | 3 + .../deepl/actions/translate-text/index.js | 77 + .../backend/src/apps/deepl/assets/favicon.svg | 39 + packages/backend/src/apps/deepl/auth/index.js | 33 + .../src/apps/deepl/auth/is-still-verified.js | 8 + .../src/apps/deepl/auth/verify-credentials.js | 9 + .../src/apps/deepl/common/add-auth-header.js | 10 + packages/backend/src/apps/deepl/index.js | 18 + .../src/apps/delay/actions/delay-for/index.js | 56 + .../apps/delay/actions/delay-until/index.js | 28 + .../backend/src/apps/delay/actions/index.js | 4 + .../backend/src/apps/delay/assets/favicon.svg | 7 + packages/backend/src/apps/delay/index.js | 14 + .../actions/create-scheduled-event/index.js | 88 + .../backend/src/apps/discord/actions/index.js | 4 + .../actions/send-message-to-channel/index.js | 48 + .../src/apps/discord/assets/favicon.svg | 4 + .../apps/discord/auth/generate-auth-url.js | 22 + .../backend/src/apps/discord/auth/index.js | 61 + .../apps/discord/auth/is-still-verified.js | 9 + .../apps/discord/auth/verify-credentials.js | 55 + .../apps/discord/common/add-auth-header.js | 10 + .../apps/discord/common/get-current-user.js | 8 + .../backend/src/apps/discord/common/scopes.js | 3 + .../src/apps/discord/dynamic-data/index.js | 4 + .../dynamic-data/list-channels/index.js | 29 + .../dynamic-data/list-voice-channels/index.js | 29 + .../src/apps/discord/dynamic-fields/index.js | 3 + .../index.js | 87 + packages/backend/src/apps/discord/index.js | 24 + .../src/apps/discord/triggers/index.js | 1 + .../src/apps/disqus/assets/favicon.svg | 16 + .../src/apps/disqus/auth/generate-auth-url.js | 21 + .../backend/src/apps/disqus/auth/index.js | 48 + .../src/apps/disqus/auth/is-still-verified.js | 8 + .../src/apps/disqus/auth/refresh-token.js | 26 + .../apps/disqus/auth/verify-credentials.js | 34 + .../src/apps/disqus/common/add-auth-header.js | 15 + .../src/apps/disqus/common/auth-scope.js | 3 + .../apps/disqus/common/get-current-user.js | 10 + .../src/apps/disqus/dynamic-data/index.js | 3 + .../disqus/dynamic-data/list-forums/index.js | 36 + packages/backend/src/apps/disqus/index.js | 20 + .../backend/src/apps/disqus/triggers/index.js | 4 + .../disqus/triggers/new-comments/index.js | 92 + .../triggers/new-flagged-comments/index.js | 60 + .../dropbox/actions/create-folder/index.js | 40 + .../backend/src/apps/dropbox/actions/index.js | 4 + .../apps/dropbox/actions/rename-file/index.js | 45 + .../src/apps/dropbox/assets/favicon.svg | 3 + .../apps/dropbox/auth/generate-auth-url.js | 22 + .../backend/src/apps/dropbox/auth/index.js | 48 + .../apps/dropbox/auth/is-still-verified.js | 8 + .../src/apps/dropbox/auth/refresh-token.js | 36 + .../apps/dropbox/auth/verify-credentials.js | 78 + .../apps/dropbox/common/add-auth-header.js | 14 + .../dropbox/common/get-current-account.js | 6 + .../backend/src/apps/dropbox/common/scopes.js | 8 + packages/backend/src/apps/dropbox/index.js | 18 + .../src/apps/filter/actions/continue/index.js | 88 + .../backend/src/apps/filter/actions/index.js | 3 + .../src/apps/filter/assets/favicon.svg | 8 + packages/backend/src/apps/filter/index.js | 14 + .../src/apps/flickr/assets/favicon.svg | 5 + .../src/apps/flickr/auth/generate-auth-url.js | 20 + .../backend/src/apps/flickr/auth/index.js | 48 + .../src/apps/flickr/auth/is-still-verified.js | 11 + .../apps/flickr/auth/verify-credentials.js | 21 + .../src/apps/flickr/common/add-auth-header.js | 33 + .../src/apps/flickr/common/oauth-client.js | 22 + .../src/apps/flickr/dynamic-data/index.js | 3 + .../flickr/dynamic-data/list-albums/index.js | 41 + packages/backend/src/apps/flickr/index.js | 21 + .../backend/src/apps/flickr/triggers/index.js | 6 + .../apps/flickr/triggers/new-albums/index.js | 13 + .../flickr/triggers/new-albums/new-albums.js | 53 + .../triggers/new-favorite-photos/index.js | 13 + .../new-favorite-photos.js | 59 + .../triggers/new-photos-in-album/index.js | 32 + .../new-photos-in-album.js | 54 + .../apps/flickr/triggers/new-photos/index.js | 13 + .../flickr/triggers/new-photos/new-photos.js | 59 + .../apps/flowers-software/assets/favicon.svg | 12 + .../src/apps/flowers-software/auth/index.js | 43 + .../auth/is-still-verified.js | 9 + .../auth/verify-credentials.js | 19 + .../common/add-auth-header.js | 16 + .../flowers-software/common/get-webhooks.js | 3 + .../common/webhook-filters.js | 488 + .../src/apps/flowers-software/index.js | 18 + .../apps/flowers-software/triggers/index.js | 3 + .../triggers/new-activity/index.js | 63 + .../apps/formatter/actions/date-time/index.js | 58 + .../transformers/format-date-time.js | 35 + .../transformers/get-current-timestamp.js | 5 + .../src/apps/formatter/actions/index.js | 5 + .../apps/formatter/actions/numbers/index.js | 60 + .../numbers/transformers/format-number.js | 27 + .../transformers/format-phone-number.js | 23 + .../transformers/perform-math-operation.js | 23 + .../numbers/transformers/random-number.js | 13 + .../src/apps/formatter/actions/text/index.js | 99 + .../text/transformers/base64-to-string.js | 8 + .../actions/text/transformers/capitalize.js | 10 + .../actions/text/transformers/create-uuid.js | 7 + .../text/transformers/encode-uri-component.js | 8 + .../actions/text/transformers/encode-uri.js | 8 + .../transformers/extract-email-address.js | 10 + .../text/transformers/extract-number.js | 24 + .../text/transformers/html-to-markdown.js | 10 + .../actions/text/transformers/lowercase.js | 6 + .../text/transformers/markdown-to-html.js | 12 + .../transformers/parse-stringified-json.js | 7 + .../actions/text/transformers/pluralize.js | 8 + .../actions/text/transformers/replace.js | 28 + .../text/transformers/string-to-base64.js | 8 + .../text/transformers/trim-whitespace.js | 6 + .../text/transformers/use-default-value.js | 11 + .../src/apps/formatter/assets/favicon.svg | 3 + .../common/phone-number-country-codes.js | 249 + .../apps/formatter/dynamic-fields/index.js | 4 + .../list-replace-regex-options/index.js | 23 + .../date-time/format-date-time.js | 51 + .../date-time/options/format.js | 64 + .../date-time/options/timezone.js | 449 + .../list-transform-options/index.js | 52 + .../numbers/format-number.js | 38 + .../numbers/format-phone-number.js | 36 + .../numbers/perform-math-operation.js | 36 + .../numbers/random-number.js | 29 + .../text/base64-to-string.js | 12 + .../list-transform-options/text/capitalize.js | 12 + .../text/encode-uri-component.js | 12 + .../list-transform-options/text/encode-uri.js | 12 + .../text/extract-email-address.js | 12 + .../text/extract-number.js | 12 + .../text/html-to-markdown.js | 12 + .../list-transform-options/text/lowercase.js | 12 + .../text/markdown-to-html.js | 12 + .../text/parse-stringified-json.js | 12 + .../list-transform-options/text/pluralize.js | 12 + .../list-transform-options/text/replace.js | 55 + .../text/string-to-base64.js | 12 + .../text/trim-whitespace.js | 12 + .../text/use-default-value.js | 21 + packages/backend/src/apps/formatter/index.js | 16 + .../src/apps/freescout/assets/favicon.svg | 25 + .../backend/src/apps/freescout/auth/index.js | 44 + .../apps/freescout/auth/is-still-verified.js | 6 + .../apps/freescout/auth/verify-credentials.js | 5 + .../apps/freescout/common/add-auth-header.js | 9 + .../src/apps/freescout/common/set-base-url.js | 6 + .../apps/freescout/common/webhook-filters.js | 52 + packages/backend/src/apps/freescout/index.js | 18 + .../src/apps/freescout/triggers/index.js | 3 + .../freescout/triggers/new-event/index.js | 61 + .../backend/src/apps/ghost/assets/favicon.svg | 60 + packages/backend/src/apps/ghost/auth/index.js | 32 + .../src/apps/ghost/auth/is-still-verified.js | 8 + .../src/apps/ghost/auth/verify-credentials.js | 14 + .../src/apps/ghost/common/add-auth-header.js | 22 + .../src/apps/ghost/common/set-base-url.js | 10 + packages/backend/src/apps/ghost/index.js | 19 + .../backend/src/apps/ghost/triggers/index.js | 3 + .../triggers/new-post-published/index.js | 55 + .../apps/github/actions/create-issue/index.js | 58 + .../backend/src/apps/github/actions/index.js | 3 + .../src/apps/github/assets/favicon.svg | 6 + .../src/apps/github/auth/generate-auth-url.js | 22 + .../backend/src/apps/github/auth/index.js | 49 + .../src/apps/github/auth/is-still-verified.js | 8 + .../apps/github/auth/verify-credentials.js | 35 + .../src/apps/github/common/add-auth-header.js | 9 + .../apps/github/common/get-current-user.js | 8 + .../github/common/get-repo-owner-and-repo.js | 10 + .../src/apps/github/common/paginate-all.js | 22 + .../src/apps/github/dynamic-data/index.js | 4 + .../github/dynamic-data/list-labels/index.js | 25 + .../github/dynamic-data/list-repos/index.js | 20 + packages/backend/src/apps/github/index.js | 22 + .../backend/src/apps/github/triggers/index.js | 6 + .../apps/github/triggers/new-issues/index.js | 86 + .../github/triggers/new-issues/new-issues.js | 47 + .../triggers/new-pull-requests/index.js | 32 + .../new-pull-requests/new-pull-requests.js | 41 + .../github/triggers/new-stargazers/index.js | 32 + .../triggers/new-stargazers/new-stargazers.js | 51 + .../github/triggers/new-watchers/index.js | 32 + .../triggers/new-watchers/new-watchers.js | 49 + .../src/apps/gitlab/assets/favicon.svg | 2 + .../src/apps/gitlab/auth/generate-auth-url.js | 23 + .../backend/src/apps/gitlab/auth/index.js | 63 + .../src/apps/gitlab/auth/is-still-verified.js | 8 + .../src/apps/gitlab/auth/refresh-token.js | 23 + .../apps/gitlab/auth/verify-credentials.js | 43 + .../src/apps/gitlab/common/add-auth-header.js | 9 + .../src/apps/gitlab/common/get-base-url.js | 13 + .../apps/gitlab/common/get-current-user.js | 9 + .../src/apps/gitlab/common/paginate-all.js | 23 + .../src/apps/gitlab/common/set-base-url.js | 11 + .../src/apps/gitlab/dynamic-data/index.js | 3 + .../dynamic-data/list-projects/index.js | 32 + packages/backend/src/apps/gitlab/index.js | 21 + .../confidential-issue-event/index.js | 28 + .../confidential-issue-event/issue_event.js | 159 + .../triggers/confidential-note-event/index.js | 28 + .../confidential-note-event/note_event.js | 74 + .../deployment-event/deployment_event.js | 45 + .../gitlab/triggers/deployment-event/index.js | 27 + .../feature-flag-event/feature_flag_event.js | 38 + .../triggers/feature-flag-event/index.js | 27 + .../backend/src/apps/gitlab/triggers/index.js | 29 + .../apps/gitlab/triggers/issue-event/index.js | 27 + .../triggers/issue-event/issue_event.js | 159 + .../apps/gitlab/triggers/job-event/index.js | 26 + .../gitlab/triggers/job-event/job_event.js | 60 + .../backend/src/apps/gitlab/triggers/lib.js | 94 + .../triggers/merge-request-event/index.js | 27 + .../merge_request_event.js | 208 + .../apps/gitlab/triggers/note-event/index.js | 27 + .../gitlab/triggers/note-event/note_event.js | 74 + .../gitlab/triggers/pipeline-event/index.js | 27 + .../triggers/pipeline-event/pipeline_event.js | 254 + .../apps/gitlab/triggers/push-event/index.js | 63 + .../gitlab/triggers/push-event/push_event.js | 75 + .../gitlab/triggers/release-event/index.js | 26 + .../triggers/release-event/release_event.js | 72 + .../gitlab/triggers/tag-push-event/index.js | 27 + .../triggers/tag-push-event/tag_push_event.js | 43 + .../backend/src/apps/gitlab/triggers/types.js | 15 + .../gitlab/triggers/wiki-page-event/index.js | 27 + .../wiki-page-event/wiki_page_event.js | 48 + .../apps/google-calendar/assets/favicon.svg | 27 + .../google-calendar/auth/generate-auth-url.js | 23 + .../src/apps/google-calendar/auth/index.js | 48 + .../google-calendar/auth/is-still-verified.js | 8 + .../google-calendar/auth/refresh-token.js | 26 + .../auth/verify-credentials.js | 42 + .../google-calendar/common/add-auth-header.js | 9 + .../apps/google-calendar/common/auth-scope.js | 7 + .../common/get-current-user.js | 8 + .../google-calendar/dynamic-data/index.js | 3 + .../dynamic-data/list-calendars/index.js | 32 + .../backend/src/apps/google-calendar/index.js | 20 + .../apps/google-calendar/triggers/index.js | 4 + .../triggers/new-calendar/index.js | 34 + .../triggers/new-event/index.js | 55 + .../src/apps/google-drive/assets/favicon.svg | 8 + .../google-drive/auth/generate-auth-url.js | 23 + .../src/apps/google-drive/auth/index.js | 48 + .../google-drive/auth/is-still-verified.js | 8 + .../apps/google-drive/auth/refresh-token.js | 26 + .../google-drive/auth/verify-credentials.js | 42 + .../google-drive/common/add-auth-header.js | 9 + .../apps/google-drive/common/auth-scope.js | 7 + .../google-drive/common/get-current-user.js | 8 + .../apps/google-drive/dynamic-data/index.js | 4 + .../dynamic-data/list-drives/index.js | 31 + .../dynamic-data/list-folders/index.js | 43 + .../backend/src/apps/google-drive/index.js | 20 + .../src/apps/google-drive/triggers/index.js | 6 + .../triggers/new-files-in-folder/index.js | 59 + .../new-files-in-folder.js | 40 + .../google-drive/triggers/new-files/index.js | 34 + .../triggers/new-files/new-files.js | 34 + .../triggers/new-folders/index.js | 59 + .../triggers/new-folders/new-folders.js | 41 + .../triggers/updated-files/index.js | 76 + .../triggers/updated-files/updated-files.js | 45 + .../src/apps/google-forms/assets/favicon.svg | 41 + .../google-forms/auth/generate-auth-url.js | 23 + .../src/apps/google-forms/auth/index.js | 48 + .../google-forms/auth/is-still-verified.js | 8 + .../apps/google-forms/auth/refresh-token.js | 26 + .../google-forms/auth/verify-credentials.js | 42 + .../google-forms/common/add-auth-header.js | 9 + .../apps/google-forms/common/auth-scope.js | 9 + .../google-forms/common/get-current-user.js | 8 + .../apps/google-forms/dynamic-data/index.js | 3 + .../dynamic-data/list-forms/index.js | 33 + .../backend/src/apps/google-forms/index.js | 20 + .../src/apps/google-forms/triggers/index.js | 3 + .../triggers/new-form-responses/index.js | 33 + .../new-form-responses/new-form-responses.js | 26 + .../actions/create-spreadsheet-row/index.js | 134 + .../actions/create-spreadsheet/index.js | 100 + .../actions/create-worksheet/index.js | 171 + .../actions/find-worksheet/index.js | 175 + .../src/apps/google-sheets/actions/index.js | 11 + .../src/apps/google-sheets/assets/favicon.svg | 89 + .../google-sheets/auth/generate-auth-url.js | 23 + .../src/apps/google-sheets/auth/index.js | 48 + .../google-sheets/auth/is-still-verified.js | 8 + .../apps/google-sheets/auth/refresh-token.js | 26 + .../google-sheets/auth/verify-credentials.js | 42 + .../google-sheets/common/add-auth-header.js | 9 + .../apps/google-sheets/common/auth-scope.js | 8 + .../google-sheets/common/get-current-user.js | 8 + .../apps/google-sheets/dynamic-data/index.js | 5 + .../dynamic-data/list-drives/index.js | 34 + .../dynamic-data/list-spreadsheets/index.js | 43 + .../dynamic-data/list-worksheets/index.js | 38 + .../google-sheets/dynamic-fields/index.js | 4 + .../list-create-worksheet-fields/index.js | 26 + .../list-sheet-headers/index.js | 55 + .../backend/src/apps/google-sheets/index.js | 24 + .../src/apps/google-sheets/triggers/index.js | 5 + .../triggers/new-spreadsheet-rows/index.js | 82 + .../new-spreadsheet-rows.js | 33 + .../triggers/new-spreadsheets/index.js | 34 + .../new-spreadsheets/new-spreadsheets.js | 37 + .../triggers/new-worksheets/index.js | 57 + .../triggers/new-worksheets/new-worksheets.js | 26 + .../actions/create-task-list/index.js | 31 + .../google-tasks/actions/create-task/index.js | 70 + .../google-tasks/actions/find-task/index.js | 57 + .../src/apps/google-tasks/actions/index.js | 6 + .../google-tasks/actions/update-task/index.js | 108 + .../src/apps/google-tasks/assets/favicon.svg | 16 + .../google-tasks/auth/generate-auth-url.js | 23 + .../src/apps/google-tasks/auth/index.js | 48 + .../google-tasks/auth/is-still-verified.js | 8 + .../apps/google-tasks/auth/refresh-token.js | 25 + .../google-tasks/auth/verify-credentials.js | 42 + .../google-tasks/common/add-auth-header.js | 9 + .../apps/google-tasks/common/auth-scope.js | 7 + .../google-tasks/common/get-current-user.js | 8 + .../apps/google-tasks/dynamic-data/index.js | 4 + .../dynamic-data/list-task-lists/index.js | 33 + .../dynamic-data/list-tasks/index.js | 40 + .../backend/src/apps/google-tasks/index.js | 22 + .../src/apps/google-tasks/triggers/index.js | 5 + .../triggers/new-completed-tasks/index.js | 59 + .../triggers/new-task-lists/index.js | 31 + .../google-tasks/triggers/new-tasks/index.js | 53 + .../backend/src/apps/helix/actions/index.js | 3 + .../src/apps/helix/actions/new-chat/index.js | 55 + .../backend/src/apps/helix/assets/favicon.svg | 6 + packages/backend/src/apps/helix/auth/index.js | 45 + .../src/apps/helix/auth/is-still-verified.js | 8 + .../src/apps/helix/auth/verify-credentials.js | 9 + .../src/apps/helix/common/add-auth-header.js | 10 + .../src/apps/helix/common/set-base-url.js | 11 + packages/backend/src/apps/helix/index.js | 19 + .../actions/custom-request/index.js | 157 + .../src/apps/http-request/actions/index.js | 3 + .../src/apps/http-request/assets/favicon.svg | 1 + .../backend/src/apps/http-request/index.js | 14 + .../hubspot/actions/create-contact/index.js | 83 + .../backend/src/apps/hubspot/actions/index.js | 3 + .../src/apps/hubspot/assets/favicon.svg | 8 + .../apps/hubspot/auth/generate-auth-url.js | 19 + .../backend/src/apps/hubspot/auth/index.js | 48 + .../apps/hubspot/auth/is-still-verified.js | 9 + .../src/apps/hubspot/auth/refresh-token.js | 27 + .../apps/hubspot/auth/verify-credentials.js | 51 + .../apps/hubspot/common/add-auth-header.js | 13 + .../hubspot/common/get-access-token-info.js | 9 + .../backend/src/apps/hubspot/common/scopes.js | 3 + packages/backend/src/apps/hubspot/index.js | 18 + .../actions/create-client/fields.js | 639 + .../actions/create-client/index.js | 84 + .../actions/create-invoice/fields.js | 407 + .../actions/create-invoice/index.js | 127 + .../actions/create-payment/fields.js | 111 + .../actions/create-payment/index.js | 42 + .../actions/create-product/fields.js | 114 + .../actions/create-product/index.js | 52 + .../src/apps/invoice-ninja/actions/index.js | 6 + .../src/apps/invoice-ninja/assets/favicon.svg | 2 + .../src/apps/invoice-ninja/auth/index.js | 33 + .../invoice-ninja/auth/is-still-verified.js | 9 + .../invoice-ninja/auth/verify-credentials.js | 13 + .../invoice-ninja/common/add-auth-header.js | 18 + .../common/filter-provided-fields.js | 18 + .../apps/invoice-ninja/common/set-base-url.js | 11 + .../apps/invoice-ninja/dynamic-data/index.js | 4 + .../dynamic-data/list-clients/index.js | 31 + .../dynamic-data/list-invoices/index.js | 31 + .../backend/src/apps/invoice-ninja/index.js | 23 + .../src/apps/invoice-ninja/triggers/index.js | 15 + .../triggers/new-clients/index.js | 54 + .../triggers/new-credits/index.js | 54 + .../triggers/new-invoices/index.js | 54 + .../triggers/new-payments/index.js | 54 + .../triggers/new-projects/index.js | 54 + .../triggers/new-quotes/index.js | 54 + .../src/apps/jotform/assets/favicon.svg | 1 + .../backend/src/apps/jotform/auth/index.js | 30 + .../apps/jotform/auth/is-still-verified.js | 8 + .../apps/jotform/auth/verify-credentials.js | 12 + .../apps/jotform/common/add-auth-header.js | 9 + .../apps/jotform/common/get-current-user.js | 7 + .../src/apps/jotform/common/set-base-url.js | 11 + .../src/apps/jotform/dynamic-data/index.js | 3 + .../jotform/dynamic-data/list-forms/index.js | 41 + packages/backend/src/apps/jotform/index.js | 21 + .../src/apps/jotform/triggers/index.js | 3 + .../jotform/triggers/new-submissions/index.js | 109 + .../actions/create-campaign/index.js | 180 + .../src/apps/mailchimp/actions/index.js | 4 + .../mailchimp/actions/send-campaign/index.js | 39 + .../src/apps/mailchimp/assets/favicon.svg | 1 + .../apps/mailchimp/auth/generate-auth-url.js | 19 + .../backend/src/apps/mailchimp/auth/index.js | 46 + .../apps/mailchimp/auth/is-still-verified.js | 8 + .../apps/mailchimp/auth/verify-credentials.js | 40 + .../apps/mailchimp/common/add-auth-header.js | 12 + .../apps/mailchimp/common/get-current-user.js | 18 + .../src/apps/mailchimp/common/set-base-url.js | 10 + .../src/apps/mailchimp/dynamic-data/index.js | 6 + .../dynamic-data/list-audiences/index.js | 40 + .../dynamic-data/list-campaigns/index.js | 42 + .../list-segments-or-tags/index.js | 44 + .../dynamic-data/list-templates/index.js | 30 + packages/backend/src/apps/mailchimp/index.js | 23 + .../mailchimp/triggers/email-opened/index.js | 101 + .../src/apps/mailchimp/triggers/index.js | 5 + .../triggers/new-subscribers/index.js | 105 + .../triggers/new-unsubscribers/index.js | 108 + .../src/apps/mailerlite/assets/favicon.svg | 1 + .../backend/src/apps/mailerlite/auth/index.js | 33 + .../apps/mailerlite/auth/is-still-verified.js | 8 + .../mailerlite/auth/verify-credentials.js | 10 + .../apps/mailerlite/common/add-auth-header.js | 9 + packages/backend/src/apps/mailerlite/index.js | 18 + .../triggers/campaign-sent/index.js | 55 + .../src/apps/mailerlite/triggers/index.js | 11 + .../triggers/spam-complaint/index.js | 78 + .../triggers/subscriber-created/index.js | 78 + .../triggers/subscriber-unsubscribed/index.js | 79 + .../src/apps/mattermost/actions/index.js | 3 + .../send-a-message-to-channel/index.js | 42 + .../send-a-message-to-channel/post-message.js | 20 + .../src/apps/mattermost/assets/favicon.svg | 6 + .../apps/mattermost/auth/generate-auth-url.js | 17 + .../backend/src/apps/mattermost/auth/index.js | 57 + .../apps/mattermost/auth/is-still-verified.js | 8 + .../mattermost/auth/verify-credentials.js | 43 + .../apps/mattermost/common/add-auth-header.js | 10 + .../common/add-x-requested-with-header.js | 9 + .../apps/mattermost/common/get-base-url.js | 5 + .../mattermost/common/get-current-user.js | 7 + .../apps/mattermost/common/set-base-url.js | 7 + .../src/apps/mattermost/dynamic-data/index.js | 3 + .../dynamic-data/list-channels/index.js | 22 + packages/backend/src/apps/mattermost/index.js | 22 + .../src/apps/miro/actions/copy-board/index.js | 116 + .../apps/miro/actions/create-board/index.js | 94 + .../miro/actions/create-card-widget/index.js | 154 + .../backend/src/apps/miro/actions/index.js | 5 + .../backend/src/apps/miro/assets/favicon.svg | 1 + .../src/apps/miro/auth/generate-auth-url.js | 19 + packages/backend/src/apps/miro/auth/index.js | 48 + .../src/apps/miro/auth/is-still-verified.js | 8 + .../src/apps/miro/auth/refresh-token.js | 22 + .../src/apps/miro/auth/verify-credentials.js | 39 + .../src/apps/miro/common/add-auth-header.js | 9 + .../src/apps/miro/common/get-current-user.js | 8 + .../src/apps/miro/dynamic-data/index.js | 4 + .../miro/dynamic-data/list-boards/index.js | 30 + .../miro/dynamic-data/list-frames/index.js | 38 + packages/backend/src/apps/miro/index.js | 20 + .../actions/create-chat-completion/index.js | 157 + .../src/apps/mistral-ai/actions/index.js | 3 + .../src/apps/mistral-ai/assets/favicon.svg | 32 + .../backend/src/apps/mistral-ai/auth/index.js | 34 + .../apps/mistral-ai/auth/is-still-verified.js | 6 + .../mistral-ai/auth/verify-credentials.js | 5 + .../apps/mistral-ai/common/add-auth-header.js | 9 + .../src/apps/mistral-ai/dynamic-data/index.js | 3 + .../dynamic-data/list-models/index.js | 17 + packages/backend/src/apps/mistral-ai/index.js | 20 + .../actions/create-database-item/index.js | 93 + .../apps/notion/actions/create-page/index.js | 98 + .../actions/find-database-item/index.js | 64 + .../backend/src/apps/notion/actions/index.js | 5 + .../src/apps/notion/assets/favicon.svg | 7 + .../src/apps/notion/auth/generate-auth-url.js | 23 + .../backend/src/apps/notion/auth/index.js | 49 + .../src/apps/notion/auth/is-still-verified.js | 8 + .../apps/notion/auth/verify-credentials.js | 52 + .../src/apps/notion/common/add-auth-header.js | 13 + .../common/add-notion-version-header.js | 7 + .../apps/notion/common/get-current-user.js | 9 + .../src/apps/notion/dynamic-data/index.js | 4 + .../dynamic-data/list-databases/index.js | 32 + .../dynamic-data/list-parent-pages/index.js | 36 + packages/backend/src/apps/notion/index.js | 23 + .../backend/src/apps/notion/triggers/index.js | 4 + .../triggers/new-database-items/index.js | 32 + .../new-database-items/new-database-items.js | 29 + .../triggers/updated-database-items/index.js | 33 + .../updated-database-items.js | 29 + .../backend/src/apps/ntfy/actions/index.js | 3 + .../apps/ntfy/actions/send-message/index.js | 96 + .../backend/src/apps/ntfy/assets/favicon.svg | 1 + packages/backend/src/apps/ntfy/auth/index.js | 43 + .../src/apps/ntfy/auth/is-still-verified.js | 8 + .../src/apps/ntfy/auth/verify-credentials.js | 14 + .../src/apps/ntfy/common/add-auth-header.js | 16 + packages/backend/src/apps/ntfy/index.js | 18 + .../apps/odoo/actions/create-lead/index.js | 98 + .../backend/src/apps/odoo/actions/index.js | 3 + .../backend/src/apps/odoo/assets/favicon.svg | 1 + packages/backend/src/apps/odoo/auth/index.js | 88 + .../src/apps/odoo/auth/is-still-verified.js | 8 + .../src/apps/odoo/auth/verify-credentials.js | 15 + .../src/apps/odoo/common/xmlrpc-client.js | 53 + packages/backend/src/apps/odoo/index.js | 16 + .../openai/actions/check-moderation/index.js | 30 + .../backend/src/apps/openai/actions/index.js | 5 + .../openai/actions/send-chat-prompt/index.js | 138 + .../apps/openai/actions/send-prompt/index.js | 110 + .../src/apps/openai/assets/favicon.svg | 6 + .../backend/src/apps/openai/auth/index.js | 34 + .../src/apps/openai/auth/is-still-verified.js | 6 + .../apps/openai/auth/verify-credentials.js | 5 + .../src/apps/openai/common/add-auth-header.js | 9 + .../src/apps/openai/dynamic-data/index.js | 3 + .../openai/dynamic-data/list-models/index.js | 17 + packages/backend/src/apps/openai/index.js | 20 + .../actions/create-chat-completion/index.js | 157 + .../src/apps/openrouter/actions/index.js | 3 + .../src/apps/openrouter/assets/favicon.svg | 1 + .../backend/src/apps/openrouter/auth/index.js | 34 + .../apps/openrouter/auth/is-still-verified.js | 6 + .../openrouter/auth/verify-credentials.js | 5 + .../apps/openrouter/common/add-auth-header.js | 9 + .../src/apps/openrouter/dynamic-data/index.js | 3 + .../dynamic-data/list-models/index.js | 17 + packages/backend/src/apps/openrouter/index.js | 20 + .../src/apps/perplexity/actions/index.js | 3 + .../actions/send-chat-prompt/index.js | 185 + .../src/apps/perplexity/assets/favicon.svg | 1 + .../backend/src/apps/perplexity/auth/index.js | 34 + .../apps/perplexity/auth/is-still-verified.js | 5 + .../perplexity/auth/verify-credentials.js | 5 + .../apps/perplexity/common/add-auth-header.js | 9 + packages/backend/src/apps/perplexity/index.js | 18 + .../actions/create-activity/index.js | 198 + .../pipedrive/actions/create-deal/index.js | 222 + .../pipedrive/actions/create-lead/index.js | 181 + .../pipedrive/actions/create-note/index.js | 198 + .../actions/create-organization/index.js | 74 + .../pipedrive/actions/create-person/index.js | 133 + .../src/apps/pipedrive/actions/index.js | 15 + .../src/apps/pipedrive/assets/favicon.svg | 1 + .../apps/pipedrive/auth/generate-auth-url.js | 18 + .../backend/src/apps/pipedrive/auth/index.js | 48 + .../apps/pipedrive/auth/is-still-verified.js | 8 + .../src/apps/pipedrive/auth/refresh-token.js | 35 + .../apps/pipedrive/auth/verify-credentials.js | 58 + .../apps/pipedrive/common/add-auth-header.js | 12 + .../common/filter-provided-fields.js | 16 + .../apps/pipedrive/common/get-current-user.js | 8 + .../src/apps/pipedrive/common/set-base-url.js | 10 + .../src/apps/pipedrive/dynamic-data/index.js | 25 + .../dynamic-data/list-activity-types/index.js | 25 + .../dynamic-data/list-currencies/index.js | 25 + .../dynamic-data/list-deals/index.js | 29 + .../dynamic-data/list-lead-labels/index.js | 26 + .../dynamic-data/list-leads/index.js | 29 + .../list-organization-label-field/index.js | 28 + .../dynamic-data/list-organizations/index.js | 25 + .../list-person-label-field/index.js | 38 + .../dynamic-data/list-persons/index.js | 33 + .../dynamic-data/list-stages/index.js | 23 + .../dynamic-data/list-users/index.js | 23 + packages/backend/src/apps/pipedrive/index.js | 23 + .../src/apps/pipedrive/triggers/index.js | 6 + .../triggers/new-activities/index.js | 38 + .../pipedrive/triggers/new-deals/index.js | 38 + .../pipedrive/triggers/new-leads/index.js | 38 + .../pipedrive/triggers/new-notes/index.js | 38 + .../src/apps/placetel/assets/favicon.svg | 6 + .../backend/src/apps/placetel/auth/index.js | 21 + .../apps/placetel/auth/is-still-verified.js | 8 + .../apps/placetel/auth/verify-credentials.js | 9 + .../apps/placetel/common/add-auth-header.js | 9 + .../src/apps/placetel/dynamic-data/index.js | 3 + .../dynamic-data/list-numbers/index.js | 27 + packages/backend/src/apps/placetel/index.js | 20 + .../placetel/triggers/hungup-call/index.js | 145 + .../src/apps/placetel/triggers/index.js | 3 + .../apps/postgresql/actions/delete/index.js | 109 + .../src/apps/postgresql/actions/index.js | 6 + .../apps/postgresql/actions/insert/index.js | 95 + .../postgresql/actions/sql-query/index.js | 57 + .../apps/postgresql/actions/update/index.js | 141 + .../src/apps/postgresql/assets/favicon.svg | 10 + .../backend/src/apps/postgresql/auth/index.js | 98 + .../apps/postgresql/auth/is-still-verified.js | 9 + .../postgresql/auth/verify-credentials.js | 25 + .../apps/postgresql/common/postgres-client.js | 20 + .../common/set-run-time-parameters.js | 14 + .../common/where-clause-operators.js | 60 + packages/backend/src/apps/postgresql/index.js | 16 + .../src/apps/pushover/actions/index.js | 3 + .../send-a-pushover-notification/index.js | 133 + .../src/apps/pushover/assets/favicon.svg | 7 + .../backend/src/apps/pushover/auth/index.js | 44 + .../apps/pushover/auth/is-still-verified.js | 8 + .../apps/pushover/auth/verify-credentials.js | 24 + .../src/apps/pushover/dynamic-data/index.js | 4 + .../dynamic-data/list-devices/index.js | 28 + .../dynamic-data/list-sounds/index.js | 26 + packages/backend/src/apps/pushover/index.js | 18 + .../reddit/actions/create-link-post/index.js | 53 + .../backend/src/apps/reddit/actions/index.js | 3 + .../src/apps/reddit/assets/favicon.svg | 1 + .../src/apps/reddit/auth/generate-auth-url.js | 25 + .../backend/src/apps/reddit/auth/index.js | 48 + .../src/apps/reddit/auth/is-still-verified.js | 8 + .../src/apps/reddit/auth/refresh-token.js | 33 + .../apps/reddit/auth/verify-credentials.js | 47 + .../src/apps/reddit/common/add-auth-header.js | 25 + .../src/apps/reddit/common/auth-scope.js | 3 + .../apps/reddit/common/get-current-user.js | 6 + packages/backend/src/apps/reddit/index.js | 20 + .../backend/src/apps/reddit/triggers/index.js | 3 + .../new-posts-matching-search/index.js | 48 + .../src/apps/removebg/actions/index.js | 3 + .../actions/remove-image-background/index.js | 83 + .../src/apps/removebg/assets/favicon.svg | 1 + .../backend/src/apps/removebg/auth/index.js | 33 + .../apps/removebg/auth/is-still-verified.js | 8 + .../apps/removebg/auth/verify-credentials.js | 10 + .../apps/removebg/common/add-auth-header.js | 9 + packages/backend/src/apps/removebg/index.js | 18 + .../backend/src/apps/rss/assets/favicon.svg | 6 + packages/backend/src/apps/rss/index.js | 14 + .../backend/src/apps/rss/triggers/index.js | 3 + .../rss/triggers/new-items-in-feed/index.js | 23 + .../new-items-in-feed/new-items-in-feed.js | 44 + .../actions/create-attachment/index.js | 52 + .../salesforce/actions/execute-query/index.js | 31 + .../find-partially-matching-record/index.js | 101 + .../salesforce/actions/find-record/index.js | 80 + .../src/apps/salesforce/actions/index.js | 6 + .../src/apps/salesforce/assets/favicon.svg | 16 + .../apps/salesforce/auth/generate-auth-url.js | 17 + .../backend/src/apps/salesforce/auth/index.js | 69 + .../apps/salesforce/auth/is-still-verified.js | 8 + .../src/apps/salesforce/auth/refresh-token.js | 24 + .../salesforce/auth/verify-credentials.js | 38 + .../apps/salesforce/common/add-auth-header.js | 15 + .../salesforce/common/get-current-user.js | 8 + .../src/apps/salesforce/dynamic-data/index.js | 4 + .../dynamic-data/list-fields/index.js | 23 + .../dynamic-data/list-objects/index.js | 17 + packages/backend/src/apps/salesforce/index.js | 22 + .../src/apps/salesforce/triggers/index.js | 3 + .../updated-field-in-records/index.js | 55 + .../updated-field-in-records.js | 43 + .../src/apps/scheduler/assets/favicon.svg | 1 + .../src/apps/scheduler/common/cron-times.js | 12 + .../scheduler/common/get-date-time-object.js | 14 + .../common/get-next-cron-date-time.js | 12 + packages/backend/src/apps/scheduler/index.js | 15 + .../scheduler/triggers/every-day/index.js | 166 + .../scheduler/triggers/every-hour/index.js | 60 + .../scheduler/triggers/every-month/index.js | 282 + .../triggers/every-n-minutes/index.js | 131 + .../scheduler/triggers/every-week/index.js | 186 + .../src/apps/scheduler/triggers/index.js | 7 + .../src/apps/self-hosted-llm/actions/index.js | 4 + .../actions/send-chat-prompt/index.js | 138 + .../actions/send-prompt/index.js | 110 + .../apps/self-hosted-llm/assets/favicon.svg | 6 + .../src/apps/self-hosted-llm/auth/index.js | 44 + .../self-hosted-llm/auth/is-still-verified.js | 6 + .../auth/verify-credentials.js | 5 + .../self-hosted-llm/common/add-auth-header.js | 9 + .../self-hosted-llm/common/set-base-url.js | 9 + .../self-hosted-llm/dynamic-data/index.js | 3 + .../dynamic-data/list-models/index.js | 17 + .../backend/src/apps/self-hosted-llm/index.js | 21 + .../actions/add-voice-xml-node/index.js | 169 + .../src/apps/signalwire/actions/index.js | 5 + .../actions/respond-with-voice-xml/index.js | 66 + .../apps/signalwire/actions/send-sms/index.js | 63 + .../src/apps/signalwire/assets/favicon.svg | 1 + .../backend/src/apps/signalwire/auth/index.js | 64 + .../apps/signalwire/auth/is-still-verified.js | 9 + .../signalwire/auth/verify-credentials.js | 11 + .../apps/signalwire/common/add-auth-header.js | 22 + .../src/apps/signalwire/dynamic-data/index.js | 15 + .../list-incoming-call-phone-numbers/index.js | 37 + .../list-incoming-sms-phone-numbers/index.js | 36 + .../list-voice-xml-children-nodes/index.js | 37 + .../index.js | 516 + .../list-voice-xml-node-attributes/index.js | 205 + .../list-voice-xml-nodes/index.js | 37 + .../apps/signalwire/dynamic-fields/index.js | 3 + .../dynamic-fields/list-node-fields/index.js | 121 + packages/backend/src/apps/signalwire/index.js | 24 + .../src/apps/signalwire/triggers/index.js | 4 + .../signalwire/triggers/receive-call/index.js | 83 + .../triggers/receive-sms/fetch-messages.js | 25 + .../signalwire/triggers/receive-sms/index.js | 33 + .../actions/find-message/find-message.js | 22 + .../apps/slack/actions/find-message/index.js | 76 + .../slack/actions/find-user-by-email/index.js | 30 + .../backend/src/apps/slack/actions/index.js | 11 + .../actions/send-a-direct-message/index.js | 77 + .../send-a-direct-message/post-message.js | 46 + .../send-a-message-to-channel/index.js | 76 + .../send-a-message-to-channel/post-message.js | 46 + .../backend/src/apps/slack/assets/favicon.svg | 6 + .../src/apps/slack/auth/generate-auth-url.js | 62 + packages/backend/src/apps/slack/auth/index.js | 46 + .../src/apps/slack/auth/is-still-verified.js | 8 + .../src/apps/slack/auth/verify-credentials.js | 45 + .../src/apps/slack/common/add-auth-header.js | 21 + .../src/apps/slack/common/get-current-user.js | 11 + .../src/apps/slack/dynamic-data/index.js | 4 + .../slack/dynamic-data/list-channels/index.js | 43 + .../slack/dynamic-data/list-users/index.js | 43 + .../src/apps/slack/dynamic-fields/index.js | 3 + .../slack/dynamic-fields/send-as-bot/index.js | 30 + packages/backend/src/apps/slack/index.js | 22 + .../backend/src/apps/smtp/actions/index.js | 3 + .../src/apps/smtp/actions/send-email/index.js | 90 + .../backend/src/apps/smtp/assets/favicon.svg | 4 + packages/backend/src/apps/smtp/auth/index.js | 91 + .../src/apps/smtp/auth/is-still-verified.js | 8 + .../src/apps/smtp/auth/verify-credentials.js | 11 + .../src/apps/smtp/common/transporter.js | 15 + packages/backend/src/apps/smtp/index.js | 16 + .../spotify/actions/create-playlist/index.js | 55 + .../backend/src/apps/spotify/actions/index.js | 3 + .../src/apps/spotify/assets/favicon.svg | 6 + .../apps/spotify/auth/generate-auth-url.js | 26 + .../backend/src/apps/spotify/auth/index.js | 47 + .../apps/spotify/auth/is-still-verified.js | 8 + .../src/apps/spotify/auth/refresh-token.js | 31 + .../apps/spotify/auth/verify-credentials.js | 52 + .../apps/spotify/common/add-auth-header.js | 13 + .../apps/spotify/common/get-current-user.js | 8 + .../backend/src/apps/spotify/common/scopes.js | 13 + packages/backend/src/apps/spotify/index.js | 18 + .../create-totals-and-stats-report/index.js | 18 + .../backend/src/apps/strava/actions/index.js | 3 + .../src/apps/strava/assets/favicon.svg | 6 + .../src/apps/strava/auth/generate-auth-url.js | 19 + .../backend/src/apps/strava/auth/index.js | 48 + .../src/apps/strava/auth/is-still-verified.js | 8 + .../src/apps/strava/auth/refresh-token.js | 20 + .../apps/strava/auth/verify-credentials.js | 19 + .../src/apps/strava/common/add-auth-header.js | 11 + .../apps/strava/common/get-current-user.js | 8 + packages/backend/src/apps/strava/index.js | 18 + .../src/apps/stripe/assets/favicon.svg | 10 + .../backend/src/apps/stripe/auth/index.js | 32 + .../src/apps/stripe/auth/is-still-verified.js | 8 + .../apps/stripe/auth/verify-credentials.js | 8 + .../src/apps/stripe/common/add-auth-header.js | 6 + packages/backend/src/apps/stripe/index.js | 19 + .../get-balance-transactions.js | 32 + .../triggers/balance-transaction/index.js | 13 + .../backend/src/apps/stripe/triggers/index.js | 4 + .../stripe/triggers/payouts/get-payouts.js | 32 + .../src/apps/stripe/triggers/payouts/index.js | 13 + .../src/apps/telegram-bot/actions/index.js | 3 + .../actions/send-message/index.js | 60 + .../src/apps/telegram-bot/assets/favicon.svg | 14 + .../src/apps/telegram-bot/auth/index.js | 21 + .../telegram-bot/auth/is-still-verified.js | 8 + .../telegram-bot/auth/verify-credentials.js | 10 + .../telegram-bot/common/add-auth-header.js | 15 + .../backend/src/apps/telegram-bot/index.js | 18 + .../apps/todoist/actions/create-task/index.js | 93 + .../backend/src/apps/todoist/actions/index.js | 3 + .../src/apps/todoist/assets/favicon.svg | 14 + .../apps/todoist/auth/generate-auth-url.js | 15 + .../backend/src/apps/todoist/auth/index.js | 58 + .../apps/todoist/auth/is-still-verified.js | 6 + .../apps/todoist/auth/verify-credentials.js | 14 + .../apps/todoist/common/add-auth-header.js | 11 + .../src/apps/todoist/dynamic-data/index.js | 5 + .../todoist/dynamic-data/list-labels/index.js | 17 + .../dynamic-data/list-projects/index.js | 17 + .../dynamic-data/list-sections/index.js | 21 + packages/backend/src/apps/todoist/index.js | 22 + .../todoist/triggers/get-tasks/get-tasks.js | 26 + .../apps/todoist/triggers/get-tasks/index.js | 80 + .../src/apps/todoist/triggers/index.js | 3 + .../actions/create-chat-completion/index.js | 169 + .../actions/create-completion/index.js | 131 + .../src/apps/together-ai/actions/index.js | 4 + .../src/apps/together-ai/assets/favicon.svg | 1 + .../src/apps/together-ai/auth/index.js | 34 + .../together-ai/auth/is-still-verified.js | 6 + .../together-ai/auth/verify-credentials.js | 5 + .../together-ai/common/add-auth-header.js | 9 + .../apps/together-ai/dynamic-data/index.js | 3 + .../dynamic-data/list-models/index.js | 17 + .../backend/src/apps/together-ai/index.js | 20 + .../apps/trello/actions/create-card/index.js | 186 + .../backend/src/apps/trello/actions/index.js | 3 + .../src/apps/trello/assets/favicon.svg | 1 + .../src/apps/trello/auth/generate-auth-url.js | 22 + .../backend/src/apps/trello/auth/index.js | 34 + .../src/apps/trello/auth/is-still-verified.js | 8 + .../apps/trello/auth/verify-credentials.js | 14 + .../src/apps/trello/common/add-auth-header.js | 11 + .../src/apps/trello/common/auth-scope.js | 3 + .../apps/trello/common/get-current-user.js | 8 + .../src/apps/trello/dynamic-data/index.js | 6 + .../dynamic-data/list-board-labels/index.js | 35 + .../dynamic-data/list-board-lists/index.js | 29 + .../trello/dynamic-data/list-boards/index.js | 23 + .../trello/dynamic-data/listMembers/index.js | 29 + packages/backend/src/apps/trello/index.js | 20 + .../backend/src/apps/twilio/actions/index.js | 3 + .../src/apps/twilio/actions/send-sms/index.js | 64 + .../src/apps/twilio/assets/favicon.svg | 11 + .../backend/src/apps/twilio/auth/index.js | 33 + .../src/apps/twilio/auth/is-still-verified.js | 8 + .../apps/twilio/auth/verify-credentials.js | 9 + .../src/apps/twilio/common/add-auth-header.js | 18 + .../common/get-incoming-phone-number.js | 7 + .../src/apps/twilio/dynamic-data/index.js | 3 + .../list-incoming-phone-numbers/index.js | 35 + packages/backend/src/apps/twilio/index.js | 22 + .../backend/src/apps/twilio/triggers/index.js | 3 + .../triggers/receive-sms/fetch-messages.js | 39 + .../apps/twilio/triggers/receive-sms/index.js | 87 + .../twitter/actions/create-tweet/index.js | 26 + .../backend/src/apps/twitter/actions/index.js | 4 + .../apps/twitter/actions/search-user/index.js | 35 + .../src/apps/twitter/assets/favicon.svg | 4 + .../apps/twitter/auth/generate-auth-url.js | 20 + .../backend/src/apps/twitter/auth/index.js | 46 + .../apps/twitter/auth/is-still-verified.js | 8 + .../apps/twitter/auth/verify-credentials.js | 19 + .../apps/twitter/common/add-auth-header.js | 39 + .../apps/twitter/common/get-current-user.js | 8 + .../twitter/common/get-user-by-username.js | 16 + .../apps/twitter/common/get-user-followers.js | 36 + .../apps/twitter/common/get-user-tweets.js | 56 + .../src/apps/twitter/common/oauth-client.js | 22 + packages/backend/src/apps/twitter/index.js | 20 + .../src/apps/twitter/triggers/index.js | 6 + .../apps/twitter/triggers/my-tweets/index.js | 13 + .../triggers/new-follower-of-me/index.js | 13 + .../new-follower-of-me/my-followers.js | 15 + .../twitter/triggers/search-tweets/index.js | 22 + .../triggers/search-tweets/search-tweets.js | 44 + .../twitter/triggers/user-tweets/index.js | 21 + .../src/apps/typeform/assets/favicon.svg | 4 + .../apps/typeform/auth/generate-auth-url.js | 20 + .../backend/src/apps/typeform/auth/index.js | 50 + .../apps/typeform/auth/is-still-verified.js | 7 + .../src/apps/typeform/auth/refresh-token.js | 23 + .../apps/typeform/auth/verify-credentials.js | 45 + .../src/apps/typeform/auth/verify-webhook.js | 20 + .../apps/typeform/common/add-auth-header.js | 10 + .../src/apps/typeform/common/auth-scope.js | 12 + .../src/apps/typeform/dynamic-data/index.js | 3 + .../typeform/dynamic-data/list-forms/index.js | 21 + packages/backend/src/apps/typeform/index.js | 20 + .../src/apps/typeform/triggers/index.js | 3 + .../apps/typeform/triggers/new-entry/index.js | 101 + .../virtualq/actions/create-waiter/index.js | 153 + .../virtualq/actions/delete-waiter/index.js | 34 + .../src/apps/virtualq/actions/index.js | 6 + .../virtualq/actions/show-waiter/index.js | 33 + .../virtualq/actions/update-waiter/index.js | 178 + .../src/apps/virtualq/assets/favicon.svg | 35 + .../backend/src/apps/virtualq/auth/index.js | 21 + .../apps/virtualq/auth/is-still-verified.js | 8 + .../apps/virtualq/auth/verify-credentials.js | 14 + .../apps/virtualq/common/add-auth-header.js | 15 + .../src/apps/virtualq/dynamic-data/index.js | 4 + .../virtualq/dynamic-data/list-lines/index.js | 15 + .../dynamic-data/list-waiters/index.js | 15 + .../src/apps/virtualq/dynamic-fields/index.js | 3 + .../list-appointment-fields/index.js | 20 + packages/backend/src/apps/virtualq/index.js | 22 + .../vtiger-crm/actions/create-case/fields.js | 408 + .../vtiger-crm/actions/create-case/index.js | 78 + .../actions/create-contact/fields.js | 649 + .../actions/create-contact/index.js | 129 + .../vtiger-crm/actions/create-lead/fields.js | 395 + .../vtiger-crm/actions/create-lead/index.js | 88 + .../actions/create-opportunity/fields.js | 244 + .../actions/create-opportunity/index.js | 64 + .../vtiger-crm/actions/create-todo/fields.js | 357 + .../vtiger-crm/actions/create-todo/index.js | 78 + .../src/apps/vtiger-crm/actions/index.js | 13 + .../src/apps/vtiger-crm/assets/favicon.svg | 925 ++ .../backend/src/apps/vtiger-crm/auth/index.js | 44 + .../apps/vtiger-crm/auth/is-still-verified.js | 8 + .../vtiger-crm/auth/verify-credentials.js | 32 + .../apps/vtiger-crm/common/add-auth-header.js | 15 + .../apps/vtiger-crm/common/set-base-url.js | 10 + .../src/apps/vtiger-crm/dynamic-data/index.js | 39 + .../dynamic-data/list-assets/index.js | 29 + .../list-campaign-sources/index.js | 29 + .../dynamic-data/list-case-options/index.js | 58 + .../list-contact-options/index.js | 62 + .../dynamic-data/list-contacts/index.js | 29 + .../dynamic-data/list-groups/index.js | 29 + .../dynamic-data/list-lead-options/index.js | 56 + .../dynamic-data/list-milestones/index.js | 29 + .../list-opportunity-options/index.js | 38 + .../dynamic-data/list-organizations/index.js | 29 + .../dynamic-data/list-products/index.js | 29 + .../dynamic-data/list-projects/index.js | 29 + .../list-record-currencies/index.js | 29 + .../list-service-contracts/index.js | 29 + .../dynamic-data/list-services/index.js | 29 + .../dynamic-data/list-sla-names/index.js | 29 + .../dynamic-data/list-tasks/index.js | 29 + .../dynamic-data/list-todo-options/index.js | 37 + packages/backend/src/apps/vtiger-crm/index.js | 23 + .../src/apps/vtiger-crm/triggers/index.js | 15 + .../vtiger-crm/triggers/new-cases/index.js | 40 + .../vtiger-crm/triggers/new-contacts/index.js | 40 + .../vtiger-crm/triggers/new-invoices/index.js | 40 + .../vtiger-crm/triggers/new-leads/index.js | 40 + .../triggers/new-opportunities/index.js | 40 + .../vtiger-crm/triggers/new-todos/index.js | 40 + .../backend/src/apps/webhook/actions/index.js | 3 + .../webhook/actions/respond-with/index.js | 69 + .../src/apps/webhook/assets/favicon.svg | 8 + packages/backend/src/apps/webhook/index.js | 16 + .../triggers/catch-raw-webhook/index.js | 52 + .../src/apps/webhook/triggers/index.js | 3 + .../src/apps/wordpress/assets/favicon.svg | 6 + .../apps/wordpress/auth/generate-auth-url.js | 26 + .../backend/src/apps/wordpress/auth/index.js | 24 + .../apps/wordpress/auth/is-still-verified.js | 7 + .../apps/wordpress/auth/verify-credentials.js | 22 + .../apps/wordpress/common/add-auth-header.js | 15 + .../apps/wordpress/common/get-instance-url.js | 5 + .../src/apps/wordpress/common/set-base-url.js | 10 + .../src/apps/wordpress/dynamic-data/index.js | 3 + .../dynamic-data/list-statuses/index.js | 27 + packages/backend/src/apps/wordpress/index.js | 21 + .../src/apps/wordpress/triggers/index.js | 5 + .../wordpress/triggers/new-comment/index.js | 59 + .../apps/wordpress/triggers/new-page/index.js | 60 + .../apps/wordpress/triggers/new-post/index.js | 60 + .../backend/src/apps/xero/assets/favicon.svg | 1 + .../src/apps/xero/auth/generate-auth-url.js | 21 + packages/backend/src/apps/xero/auth/index.js | 48 + .../src/apps/xero/auth/is-still-verified.js | 8 + .../src/apps/xero/auth/refresh-token.js | 39 + .../src/apps/xero/auth/verify-credentials.js | 52 + .../src/apps/xero/common/add-auth-header.js | 16 + .../src/apps/xero/common/auth-scope.js | 10 + .../src/apps/xero/common/get-current-user.js | 6 + .../src/apps/xero/dynamic-data/index.js | 3 + .../dynamic-data/list-organizations/index.js | 23 + packages/backend/src/apps/xero/index.js | 20 + .../backend/src/apps/xero/triggers/index.js | 4 + .../triggers/new-bank-transactions/index.js | 60 + .../apps/xero/triggers/new-payments/index.js | 103 + .../apps/you-need-a-budget/assets/favicon.svg | 25 + .../auth/generate-auth-url.js | 22 + .../src/apps/you-need-a-budget/auth/index.js | 60 + .../auth/is-still-verified.js | 8 + .../you-need-a-budget/auth/refresh-token.js | 25 + .../auth/verify-credentials.js | 33 + .../common/add-auth-header.js | 9 + .../common/get-current-user.js | 6 + .../src/apps/you-need-a-budget/index.js | 18 + .../triggers/category-overspent/index.js | 35 + .../triggers/goal-completed/index.js | 34 + .../apps/you-need-a-budget/triggers/index.js | 11 + .../triggers/low-account-balance/index.js | 41 + .../triggers/new-transactions/index.js | 24 + .../src/apps/youtube/assets/favicon.svg | 4 + .../apps/youtube/auth/generate-auth-url.js | 23 + .../backend/src/apps/youtube/auth/index.js | 48 + .../apps/youtube/auth/is-still-verified.js | 8 + .../src/apps/youtube/auth/refresh-token.js | 26 + .../apps/youtube/auth/verify-credentials.js | 42 + .../apps/youtube/common/add-auth-header.js | 9 + .../src/apps/youtube/common/auth-scope.js | 9 + .../apps/youtube/common/get-current-user.js | 8 + packages/backend/src/apps/youtube/index.js | 18 + .../src/apps/youtube/triggers/index.js | 4 + .../triggers/new-video-by-search/index.js | 48 + .../triggers/new-video-in-channel/index.js | 49 + .../zendesk/actions/create-ticket/fields.js | 301 + .../zendesk/actions/create-ticket/index.js | 93 + .../zendesk/actions/create-user/fields.js | 102 + .../apps/zendesk/actions/create-user/index.js | 48 + .../zendesk/actions/delete-ticket/index.js | 35 + .../apps/zendesk/actions/delete-user/index.js | 43 + .../apps/zendesk/actions/find-ticket/index.js | 32 + .../backend/src/apps/zendesk/actions/index.js | 15 + .../zendesk/actions/update-ticket/fields.js | 167 + .../zendesk/actions/update-ticket/index.js | 57 + .../src/apps/zendesk/assets/favicon.svg | 1 + .../apps/zendesk/auth/generate-auth-url.js | 22 + .../backend/src/apps/zendesk/auth/index.js | 55 + .../apps/zendesk/auth/is-still-verified.js | 8 + .../apps/zendesk/auth/verify-credentials.js | 55 + .../apps/zendesk/common/add-auth-headers.js | 15 + .../src/apps/zendesk/common/auth-scope.js | 3 + .../apps/zendesk/common/get-current-user.js | 8 + .../src/apps/zendesk/dynamic-data/index.js | 20 + .../zendesk/dynamic-data/list-brands/index.js | 34 + .../list-first-page-of-tickets/index.js | 29 + .../zendesk/dynamic-data/list-groups/index.js | 34 + .../dynamic-data/list-organizations/index.js | 34 + .../list-sharing-agreements/index.js | 36 + .../dynamic-data/list-ticket-forms/index.js | 34 + .../zendesk/dynamic-data/list-users/index.js | 39 + .../zendesk/dynamic-data/list-views/index.js | 34 + packages/backend/src/apps/zendesk/index.js | 22 + .../src/apps/zendesk/triggers/index.js | 4 + .../zendesk/triggers/new-tickets/index.js | 59 + .../apps/zendesk/triggers/new-users/index.js | 83 + packages/backend/src/config/app.js | 123 + packages/backend/src/config/cors-options.js | 10 + packages/backend/src/config/database.js | 22 + packages/backend/src/config/orm.js | 4 + packages/backend/src/config/redis.js | 32 + .../v1/access-tokens/create-access-token.js | 13 + .../access-tokens/create-access-token.test.js | 39 + .../v1/access-tokens/revoke-access-token.js | 15 + .../access-tokens/revoke-access-token.test.js | 54 + .../api/v1/admin/apps/create-config.ee.js | 20 + .../v1/admin/apps/create-config.ee.test.js | 66 + .../v1/admin/apps/create-oauth-client.ee.js | 25 + .../admin/apps/create-oauth-client.ee.test.js | 122 + .../api/v1/admin/apps/get-oauth-client.ee.js | 11 + .../v1/admin/apps/get-oauth-client.ee.test.js | 55 + .../api/v1/admin/apps/get-oauth-clients.ee.js | 10 + .../admin/apps/get-oauth-clients.ee.test.js | 44 + .../api/v1/admin/apps/update-config.ee.js | 26 + .../v1/admin/apps/update-config.ee.test.js | 87 + .../v1/admin/apps/update-oauth-client.ee.js | 22 + .../admin/apps/update-oauth-client.ee.test.js | 104 + .../api/v1/admin/config/update.ee.js | 28 + .../api/v1/admin/config/update.ee.test.js | 88 + .../permissions/get-permissions-catalog.ee.js | 6 + .../get-permissions-catalog.ee.test.js | 32 + .../api/v1/admin/roles/create-role.ee.js | 22 + .../api/v1/admin/roles/create-role.ee.test.js | 109 + .../api/v1/admin/roles/delete-role.ee.js | 11 + .../api/v1/admin/roles/delete-role.ee.test.js | 95 + .../api/v1/admin/roles/get-role.ee.js | 16 + .../api/v1/admin/roles/get-role.ee.test.js | 59 + .../api/v1/admin/roles/get-roles.ee.js | 8 + .../api/v1/admin/roles/get-roles.ee.test.js | 33 + .../api/v1/admin/roles/update-role.ee.js | 24 + .../api/v1/admin/roles/update-role.ee.test.js | 177 + .../create-saml-auth-provider.ee.js | 43 + .../create-saml-auth-provider.ee.test.js | 78 + .../get-role-mappings.ee.js | 14 + .../get-role-mappings.ee.test.js | 51 + .../get-saml-auth-provider.ee.js | 12 + .../get-saml-auth-provider.ee.test.js | 57 + .../get-saml-auth-providers.ee.js | 13 + .../get-saml-auth-providers.ee.test.js | 39 + .../update-role-mappings.ee.js | 25 + .../update-role-mappings.ee.test.js | 152 + .../update-saml-auth-provider.ee.js | 45 + .../update-saml-auth-provider.ee.test.js | 119 + .../api/v1/admin/users/create-user.js | 22 + .../api/v1/admin/users/create-user.test.js | 122 + .../api/v1/admin/users/delete-user.js | 10 + .../api/v1/admin/users/delete-user.test.js | 43 + .../api/v1/admin/users/get-user.ee.js | 13 + .../api/v1/admin/users/get-user.ee.test.js | 55 + .../api/v1/admin/users/get-users.ee.js | 15 + .../api/v1/admin/users/get-users.ee.test.js | 45 + .../api/v1/admin/users/update-user.ee.js | 18 + .../api/v1/admin/users/update-user.ee.test.js | 88 + .../api/v1/apps/create-connection.js | 27 + .../api/v1/apps/create-connection.test.js | 394 + .../api/v1/apps/get-action-substeps.js | 11 + .../api/v1/apps/get-action-substeps.test.js | 52 + .../controllers/api/v1/apps/get-actions.js | 8 + .../api/v1/apps/get-actions.test.js | 35 + .../src/controllers/api/v1/apps/get-app.js | 8 + .../controllers/api/v1/apps/get-app.test.js | 35 + .../src/controllers/api/v1/apps/get-apps.js | 16 + .../controllers/api/v1/apps/get-apps.test.js | 63 + .../src/controllers/api/v1/apps/get-auth.js | 8 + .../controllers/api/v1/apps/get-auth.test.js | 35 + .../controllers/api/v1/apps/get-config.ee.js | 15 + .../api/v1/apps/get-config.ee.test.js | 43 + .../api/v1/apps/get-connections.js | 24 + .../api/v1/apps/get-connections.test.js | 101 + .../src/controllers/api/v1/apps/get-flows.js | 24 + .../controllers/api/v1/apps/get-flows.test.js | 129 + .../api/v1/apps/get-oauth-client.ee.js | 11 + .../api/v1/apps/get-oauth-client.ee.test.js | 50 + .../api/v1/apps/get-oauth-clients.ee.js | 10 + .../api/v1/apps/get-oauth-clients.ee.test.js | 42 + .../api/v1/apps/get-trigger-substeps.js | 11 + .../api/v1/apps/get-trigger-substeps.test.js | 52 + .../controllers/api/v1/apps/get-triggers.js | 8 + .../api/v1/apps/get-triggers.test.js | 35 + .../api/v1/automatisch/config.ee.js | 8 + .../api/v1/automatisch/config.ee.test.js | 47 + .../controllers/api/v1/automatisch/info.js | 18 + .../api/v1/automatisch/info.test.js | 25 + .../controllers/api/v1/automatisch/license.js | 15 + .../api/v1/automatisch/license.test.js | 23 + .../api/v1/automatisch/notifications.js | 19 + .../api/v1/automatisch/notifications.test.js | 9 + .../controllers/api/v1/automatisch/version.js | 6 + .../api/v1/automatisch/version.test.js | 26 + .../api/v1/connections/delete-connection.js | 11 + .../v1/connections/delete-connection.test.js | 77 + .../api/v1/connections/generate-auth-url.js | 14 + .../v1/connections/generate-auth-url.test.js | 90 + .../api/v1/connections/get-flows.js | 21 + .../api/v1/connections/get-flows.test.js | 128 + .../api/v1/connections/reset-connection.js | 14 + .../v1/connections/reset-connection.test.js | 112 + .../api/v1/connections/test-connection.js | 14 + .../v1/connections/test-connection.test.js | 123 + .../api/v1/connections/update-connection.js | 19 + .../v1/connections/update-connection.test.js | 116 + .../api/v1/connections/verify-connection.js | 14 + .../v1/connections/verify-connection.test.js | 82 + .../api/v1/executions/get-execution-steps.js | 23 + .../v1/executions/get-execution-steps.test.js | 153 + .../api/v1/executions/get-execution.js | 16 + .../api/v1/executions/get-execution.test.js | 134 + .../api/v1/executions/get-executions.js | 27 + .../api/v1/executions/get-executions.test.js | 119 + .../controllers/api/v1/flows/create-flow.js | 11 + .../api/v1/flows/create-flow.test.js | 41 + .../controllers/api/v1/flows/create-step.js | 14 + .../api/v1/flows/create-step.test.js | 176 + .../controllers/api/v1/flows/delete-flow.js | 10 + .../api/v1/flows/delete-flow.test.js | 110 + .../api/v1/flows/duplicate-flow.js | 11 + .../api/v1/flows/duplicate-flow.test.js | 204 + .../controllers/api/v1/flows/export-flow.js | 11 + .../api/v1/flows/export-flow.test.js | 202 + .../src/controllers/api/v1/flows/get-flow.js | 12 + .../controllers/api/v1/flows/get-flow.test.js | 102 + .../src/controllers/api/v1/flows/get-flows.js | 21 + .../api/v1/flows/get-flows.test.js | 118 + .../controllers/api/v1/flows/import-flow.js | 29 + .../api/v1/flows/import-flow.test.js | 355 + .../api/v1/flows/update-flow-folder.js | 14 + .../api/v1/flows/update-flow-folder.test.js | 176 + .../api/v1/flows/update-flow-status.js | 14 + .../api/v1/flows/update-flow-status.test.js | 213 + .../controllers/api/v1/flows/update-flow.js | 15 + .../api/v1/flows/update-flow.test.js | 166 + .../api/v1/folders/create-folder.js | 11 + .../api/v1/folders/create-folder.test.js | 43 + .../api/v1/folders/delete-folder.js | 10 + .../api/v1/folders/delete-folder.test.js | 62 + .../controllers/api/v1/folders/get-folders.js | 9 + .../api/v1/folders/get-folders.test.js | 53 + .../api/v1/folders/update-folder.js | 16 + .../api/v1/folders/update-folder.test.js | 99 + .../api/v1/installation/users/create-user.js | 9 + .../v1/installation/users/create-user.test.js | 83 + .../api/v1/payment/get-paddle-info.ee.js | 8 + .../api/v1/payment/get-paddle-info.ee.test.js | 33 + .../api/v1/payment/get-plans.ee.js | 8 + .../api/v1/payment/get-plans.ee.test.js | 29 + .../get-saml-auth-providers.ee.js | 12 + .../get-saml-auth-providers.ee.test.js | 30 + .../api/v1/steps/create-dynamic-data.js | 17 + .../api/v1/steps/create-dynamic-data.test.js | 244 + .../api/v1/steps/create-dynamic-fields.js | 17 + .../v1/steps/create-dynamic-fields.test.js | 170 + .../controllers/api/v1/steps/delete-step.js | 9 + .../api/v1/steps/delete-step.test.js | 134 + .../api/v1/steps/get-connection.js | 11 + .../api/v1/steps/get-connection.test.js | 121 + .../api/v1/steps/get-previous-steps.js | 27 + .../api/v1/steps/get-previous-steps.test.js | 173 + .../src/controllers/api/v1/steps/test-step.js | 12 + .../api/v1/steps/test-step.test.js | 209 + .../controllers/api/v1/steps/update-step.js | 23 + .../api/v1/steps/update-step.test.js | 213 + .../api/v1/users/accept-invitation.js | 21 + .../api/v1/users/delete-current-user.js | 5 + .../api/v1/users/delete-current-user.test.js | 21 + .../api/v1/users/forgot-password.js | 13 + .../api/v1/users/forgot-password.test.js | 30 + .../src/controllers/api/v1/users/get-apps.js | 7 + .../controllers/api/v1/users/get-apps.test.js | 210 + .../api/v1/users/get-current-user.js | 5 + .../api/v1/users/get-current-user.test.js | 44 + .../api/v1/users/get-invoices.ee.js | 7 + .../api/v1/users/get-invoices.ee.test.js | 34 + .../api/v1/users/get-plan-and-usage.ee.js | 7 + .../v1/users/get-plan-and-usage.ee.test.js | 68 + .../api/v1/users/get-subscription.ee.js | 9 + .../api/v1/users/get-subscription.ee.test.js | 51 + .../api/v1/users/get-user-trial.ee.js | 12 + .../api/v1/users/get-user-trial.ee.test.js | 38 + .../api/v1/users/register-user.ee.js | 18 + .../api/v1/users/register-user.ee.test.js | 96 + .../api/v1/users/reset-password.js | 23 + .../api/v1/users/reset-password.test.js | 49 + .../v1/users/update-current-user-password.js | 12 + .../update-current-user-password.test.js | 51 + .../api/v1/users/update-current-user.js | 14 + .../api/v1/users/update-current-user.test.js | 57 + .../src/controllers/healthcheck/index.js | 3 + .../src/controllers/healthcheck/index.test.js | 9 + .../src/controllers/paddle/webhooks.ee.js | 47 + .../webhooks/handler-by-flow-id.js | 31 + .../webhooks/handler-sync-by-flow-id.js | 29 + .../migrations/20211005151457_create_users.js | 13 + .../20211011120732_create_credentials.js | 16 + ...55_remove_display_name_from_credentials.js | 11 + ...04154_rename_credentials_as_connections.js | 7 + .../migrations/20211106214730_create_steps.js | 16 + .../migrations/20211122140336_create_flows.js | 13 + .../20211122140612_add_flow_id_to_steps.js | 11 + ...105151725_remove_constraints_from_steps.js | 15 + ...220108141045_add_active_column_to_flows.js | 11 + .../20220127141941_add_position_to_steps.js | 11 + .../20220205145128_add_status_to_steps.js | 11 + .../20220219093113_create_executions.js | 13 + .../20220219100800_create_execution_steps.js | 16 + ...225128_alter_columns_of_execution_steps.js | 13 + ...225537_alter_parameters_column_of_steps.js | 11 + ...7104324_add_draft_column_to_connections.js | 11 + ...121241_add_published_at_column_to_flows.js | 11 + ...823171017_add_internal_id_to_executions.js | 11 + ...160521_add_raw_error_to_execution_steps.js | 11 + .../20220928162525_soft-delete-base-model.js | 29 + ...214184855_add_remote_webhook_id_in_flow.js | 11 + .../20230218110748_add_role_to_users.js | 13 + ...24_alter_role_to_not_nullable_for_users.js | 11 + ...50517_add_reset_password_token_to_users.js | 11 + ...d_reset_password_token_sent_at_to_users.js | 11 + .../20230301211751_add_full_name_to_users.js | 13 + .../20230303134548_create_payment_plans.js | 23 + .../20230303180902_create_usage_data.js | 19 + ...alter_consumed_task_count_of_usage_data.js | 17 + ...18220822_add_trial_expiry_date_to_users.js | 17 + .../20230323145809_create_subscriptions.js | 26 + ...4210051_add_deleted_at_to_subscriptions.js | 17 + ...83738_add_subscription_id_in_usage_data.js | 17 + ...llation_effective_date_to_subscriptions.js | 17 + .../20230415134138_drop_payment_plans.js | 24 + ...20230609201228_add_webhook_path_in_step.js | 11 + ...9_populate_data_in_webhook_path_in_step.js | 23 + .../migrations/20230615200200_create_roles.js | 43 + .../20230615205857_create_permissions.js | 66 + .../20230615215004_add_role_id_to_users.js | 27 + ...30623115503_remove_role_column_in_users.js | 11 + ...230702210636_create_saml_auth_providers.js | 22 + .../20230707094923_create_identities.js | 15 + ...30715214424_make_user_password_nullable.js | 9 + ...07114158_seed_saml_permissions_to_admin.js | 27 + .../20230810124730_create_config.js | 13 + ...seed_update_config_permissions_to_admin.js | 22 + ...reate_saml_auth_providers_role_mappings.js | 22 + .../20230812132005_create_app_configs.js | 15 + .../20230813172729_create_app_auth_clients.js | 19 + ...2_add_app_auth_client_id_in_connections.js | 14 + ...44_seed_update_app_permissions_to_admin.js | 22 + ...3027_make_role_id_not_nullable_in_users.js | 23 + ...t_delete_soft_deleted_user_associations.js | 33 + ..._convert_permission_conditions_to_array.js | 9 + ...094544_convert_user_emails_to_lowercase.js | 11 + ...5101146_add_flow_id_index_in_executions.js | 11 + ...1923_add_updated_at_index_in_executions.js | 11 + .../20240227164849_create_datastore_model.js | 16 + ...6194638_add_app_key_to_app_auth_clients.js | 11 + ...195028_migrate_app_config_id_to_app_key.js | 17 + ...ove_app_config_id_from_app_auth_clients.js | 11 + ...nullable_to_app_key_of_app_auth_clients.js | 11 + .../20240422130323_create_access_tokens.js | 15 + ...0424100113_add_indexes_to_access_tokens.js | 13 + ...47_add_saml_session_id_in_access_tokens.js | 11 + ...pdate_installation_completed_for_config.js | 17 + ...750_make_value_column_text_in_datastore.js | 11 + .../20240708140250_add_status_to_users.js | 11 + ...708141218_add_invitation_token_to_users.js | 13 + .../20240903110620_make_role_name_unique.js | 11 + ...240904091615_remove_key_column_in_roles.js | 19 + ...0240919100138_make_config_single_record.js | 105 + ...5_add_connection_allowed_to_app_configs.js | 37 + ...ustom_connection_allowed_in_app_configs.js | 11 + ...130418_make_key_primary_for_app_configs.js | 13 + ...31158_remove_id_column_from_app_configs.js | 11 + ...roviders_role_mappings_as_role_mappings.js | 52 + ...y_predefined_auth_clients_in_app_config.js | 11 + ...55_remove_obsolete_fields_in_app_config.js | 15 + ...hange_app_auth_clients_as_oauth_clients.js | 31 + ...20250106114602_add_name_column_to_steps.js | 26 + .../20250124105728_create_folders.js | 13 + .../20250131171406_add_folder_id_to_flows.js | 12 + .../backend/src/errors/already-processed.js | 3 + packages/backend/src/errors/base.js | 33 + packages/backend/src/errors/early-exit.js | 3 + .../backend/src/errors/generate-auth-url.js | 10 + packages/backend/src/errors/http.js | 10 + packages/backend/src/errors/not-authorized.js | 3 + packages/backend/src/errors/quote-exceeded.js | 9 + .../src/helpers/add-authentication-steps.js | 128 + .../src/helpers/add-reconnection-steps.js | 56 + .../backend/src/helpers/allow-installation.js | 16 + .../backend/src/helpers/app-assets-handler.js | 24 + .../backend/src/helpers/app-info-converter.js | 30 + .../backend/src/helpers/authentication.js | 48 + .../src/helpers/authentication.test.js | 33 + packages/backend/src/helpers/authorization.js | 175 + .../backend/src/helpers/axios-with-proxy.js | 102 + .../src/helpers/axios-with-proxy.test.js | 169 + .../backend/src/helpers/billing/index.ee.js | 18 + .../backend/src/helpers/billing/paddle.ee.js | 53 + .../backend/src/helpers/billing/plans.ee.js | 29 + .../src/helpers/billing/webhooks.ee.js | 80 + .../backend/src/helpers/check-is-cloud.js | 11 + .../src/helpers/check-is-enterprise.js | 9 + .../src/helpers/check-worker-readiness.js | 11 + .../backend/src/helpers/compile-email.ee.js | 15 + .../backend/src/helpers/compute-parameters.js | 165 + .../src/helpers/compute-parameters.test.js | 487 + .../helpers/create-auth-token-by-user-id.js | 21 + .../src/helpers/create-bull-board-handler.js | 43 + packages/backend/src/helpers/define-action.js | 3 + packages/backend/src/helpers/define-app.js | 3 + .../backend/src/helpers/define-trigger.js | 28 + .../src/helpers/delay-as-milliseconds.js | 19 + .../src/helpers/delay-for-as-milliseconds.js | 16 + .../helpers/delay-until-as-milliseconds.js | 8 + packages/backend/src/helpers/error-handler.js | 84 + packages/backend/src/helpers/export-flow.js | 45 + ...find-or-create-user-by-saml-identity.ee.js | 64 + packages/backend/src/helpers/get-app.js | 94 + .../backend/src/helpers/global-variable.js | 175 + .../backend/src/helpers/http-client/index.js | 43 + packages/backend/src/helpers/import-flow.js | 75 + .../src/helpers/inject-bull-board-handler.js | 27 + packages/backend/src/helpers/license.ee.js | 37 + packages/backend/src/helpers/logger.js | 46 + packages/backend/src/helpers/mailer.ee.js | 14 + packages/backend/src/helpers/morgan.js | 14 + .../backend/src/helpers/pagination-rest.js | 25 + packages/backend/src/helpers/pagination.js | 23 + .../backend/src/helpers/parse-header-link.js | 29 + packages/backend/src/helpers/passport.js | 129 + .../src/helpers/permission-catalog.ee.js | 72 + .../src/helpers/remove-job-configuration.js | 10 + packages/backend/src/helpers/renderer.js | 94 + packages/backend/src/helpers/sentry.ee.js | 54 + .../backend/src/helpers/telemetry/index.js | 149 + .../src/helpers/telemetry/instance-id.js | 7 + .../src/helpers/telemetry/organization-id.js | 13 + packages/backend/src/helpers/user-ability.js | 23 + .../backend/src/helpers/user-ability.test.js | 46 + .../backend/src/helpers/web-ui-handler.js | 25 + .../src/helpers/webhook-handler-sync.js | 109 + .../backend/src/helpers/webhook-handler.js | 84 + packages/backend/src/jobs/delete-user.ee.js | 37 + packages/backend/src/jobs/execute-action.js | 46 + packages/backend/src/jobs/execute-flow.js | 54 + packages/backend/src/jobs/execute-trigger.js | 32 + .../jobs/remove-cancelled-subscriptions.ee.js | 15 + packages/backend/src/jobs/send-email.js | 31 + .../__snapshots__/access-token.test.js.snap | 41 + .../__snapshots__/app-config.test.js.snap | 33 + .../src/models/__snapshots__/app.test.js.snap | 81 + .../models/__snapshots__/config.test.js.snap | 52 + .../__snapshots__/connection.test.js.snap | 51 + .../__snapshots__/datastore.test.js.snap | 36 + .../__snapshots__/execution-step.test.js.snap | 54 + .../__snapshots__/execution.test.js.snap | 33 + .../models/__snapshots__/flow.test.js.snap | 49 + .../models/__snapshots__/folder.test.js.snap | 27 + .../__snapshots__/identity.ee.test.js.snap | 37 + .../__snapshots__/oauth-client.test.js.snap | 39 + .../__snapshots__/permission.test.js.snap | 42 + .../role-mapping.ee.test.js.snap | 30 + .../models/__snapshots__/role.test.js.snap | 33 + .../saml-auth-provider.ee.test.js.snap | 72 + ...uth-providers-role-mapping.ee.test.js.snap | 30 + .../models/__snapshots__/step.test.js.snap | 85 + .../subscription.ee.test.js.snap | 63 + .../__snapshots__/usage-data.ee.test.js.snap | 41 + .../models/__snapshots__/user.test.js.snap | 81 + packages/backend/src/models/access-token.js | 67 + .../backend/src/models/access-token.test.js | 84 + packages/backend/src/models/app-config.js | 66 + .../backend/src/models/app-config.test.js | 56 + packages/backend/src/models/app.js | 115 + packages/backend/src/models/app.test.js | 418 + packages/backend/src/models/base.js | 40 + packages/backend/src/models/config.js | 84 + packages/backend/src/models/config.test.js | 137 + packages/backend/src/models/connection.js | 265 + .../backend/src/models/connection.test.js | 702 + packages/backend/src/models/datastore.js | 24 + packages/backend/src/models/datastore.test.js | 12 + packages/backend/src/models/execution-step.js | 78 + .../backend/src/models/execution-step.test.js | 152 + packages/backend/src/models/execution.js | 48 + packages/backend/src/models/execution.test.js | 52 + packages/backend/src/models/flow.js | 484 + packages/backend/src/models/flow.test.js | 665 + packages/backend/src/models/folder.js | 30 + packages/backend/src/models/folder.test.js | 31 + packages/backend/src/models/identity.ee.js | 41 + .../backend/src/models/identity.ee.test.js | 40 + packages/backend/src/models/oauth-client.js | 90 + .../backend/src/models/oauth-client.test.js | 192 + packages/backend/src/models/permission.js | 57 + .../backend/src/models/permission.test.js | 95 + packages/backend/src/models/query-builder.js | 70 + .../backend/src/models/role-mapping.ee.js | 31 + .../src/models/role-mapping.ee.test.js | 31 + packages/backend/src/models/role.js | 174 + packages/backend/src/models/role.test.js | 287 + .../src/models/saml-auth-provider.ee.js | 155 + .../src/models/saml-auth-provider.ee.test.js | 231 + packages/backend/src/models/step.js | 356 + packages/backend/src/models/step.test.js | 598 + .../backend/src/models/subscription.ee.js | 89 + .../src/models/subscription.ee.test.js | 108 + packages/backend/src/models/usage-data.ee.js | 51 + .../backend/src/models/usage-data.ee.test.js | 52 + packages/backend/src/models/user.js | 679 + packages/backend/src/models/user.test.js | 1541 +++ packages/backend/src/queues/action.js | 4 + packages/backend/src/queues/delete-user.ee.js | 4 + packages/backend/src/queues/email.js | 4 + packages/backend/src/queues/flow.js | 4 + packages/backend/src/queues/index.js | 21 + packages/backend/src/queues/queue.js | 44 + .../remove-cancelled-subscriptions.ee.js | 8 + packages/backend/src/queues/trigger.js | 4 + .../src/routes/api/v1/access-tokens.js | 11 + .../src/routes/api/v1/admin/apps.ee.js | 62 + .../src/routes/api/v1/admin/config.ee.js | 17 + .../src/routes/api/v1/admin/permissions.ee.js | 17 + .../src/routes/api/v1/admin/roles.ee.js | 53 + .../api/v1/admin/saml-auth-providers.ee.js | 62 + .../src/routes/api/v1/admin/users.ee.js | 18 + packages/backend/src/routes/api/v1/apps.js | 78 + .../backend/src/routes/api/v1/automatisch.js | 17 + .../backend/src/routes/api/v1/connections.js | 63 + .../backend/src/routes/api/v1/executions.js | 26 + packages/backend/src/routes/api/v1/flows.js | 62 + packages/backend/src/routes/api/v1/folders.js | 22 + .../src/routes/api/v1/installation/users.js | 9 + .../backend/src/routes/api/v1/payment.ee.js | 12 + .../routes/api/v1/saml-auth-providers.ee.js | 9 + packages/backend/src/routes/api/v1/steps.js | 47 + packages/backend/src/routes/api/v1/users.js | 60 + packages/backend/src/routes/healthcheck.js | 8 + packages/backend/src/routes/index.js | 48 + packages/backend/src/routes/paddle.ee.js | 8 + packages/backend/src/routes/webhooks.js | 45 + packages/backend/src/serializers/action.js | 9 + .../backend/src/serializers/action.test.js | 21 + .../admin-saml-auth-provider.ee.js | 18 + .../admin-saml-auth-provider.ee.test.js | 32 + .../backend/src/serializers/admin/user.js | 11 + .../src/serializers/admin/user.test.js | 19 + .../backend/src/serializers/app-config.js | 11 + .../src/serializers/app-config.test.js | 23 + packages/backend/src/serializers/app.js | 23 + packages/backend/src/serializers/app.test.js | 21 + packages/backend/src/serializers/auth.js | 11 + packages/backend/src/serializers/auth.test.js | 19 + packages/backend/src/serializers/config.js | 20 + .../backend/src/serializers/config.test.js | 32 + .../backend/src/serializers/connection.js | 15 + .../src/serializers/connection.test.js | 27 + .../backend/src/serializers/execution-step.js | 21 + .../src/serializers/execution-step.test.js | 45 + packages/backend/src/serializers/execution.js | 22 + .../backend/src/serializers/execution.test.js | 52 + packages/backend/src/serializers/flow.js | 25 + packages/backend/src/serializers/flow.test.js | 46 + packages/backend/src/serializers/folder.js | 10 + .../backend/src/serializers/folder.test.js | 22 + packages/backend/src/serializers/index.js | 49 + .../backend/src/serializers/oauth-client.js | 10 + .../src/serializers/oauth-client.test.js | 22 + .../backend/src/serializers/permission.js | 13 + .../src/serializers/permission.test.js | 25 + .../src/serializers/role-mapping.ee.js | 10 + packages/backend/src/serializers/role.js | 23 + packages/backend/src/serializers/role.test.js | 52 + .../src/serializers/saml-auth-provider.ee.js | 10 + .../serializers/saml-auth-provider.ee.test.js | 24 + packages/backend/src/serializers/step.js | 32 + packages/backend/src/serializers/step.test.js | 58 + .../src/serializers/subscription.ee.js | 20 + .../src/serializers/subscription.ee.test.js | 35 + packages/backend/src/serializers/trigger.js | 12 + .../backend/src/serializers/trigger.test.js | 21 + packages/backend/src/serializers/user-app.js | 22 + packages/backend/src/serializers/user.js | 32 + packages/backend/src/serializers/user.test.js | 81 + packages/backend/src/server.js | 18 + packages/backend/src/services/action.js | 79 + packages/backend/src/services/flow.js | 49 + packages/backend/src/services/test-run.js | 61 + packages/backend/src/services/trigger.js | 37 + .../views/emails/invitation-instructions.hbs | 23 + .../emails/reset-password-instructions.ee.hbs | 23 + packages/backend/src/worker.js | 23 + packages/backend/src/workers/action.js | 6 + .../backend/src/workers/delete-user.ee.js | 6 + packages/backend/src/workers/email.js | 6 + packages/backend/src/workers/flow.js | 6 + packages/backend/src/workers/index.js | 21 + .../remove-cancelled-subscriptions.ee.js | 9 + packages/backend/src/workers/trigger.js | 6 + packages/backend/src/workers/worker.js | 28 + .../test/assertions/to-require-property.js | 31 + .../backend/test/factories/access-token.js | 13 + packages/backend/test/factories/app-config.js | 10 + packages/backend/test/factories/app.js | 72 + packages/backend/test/factories/config.js | 19 + packages/backend/test/factories/connection.js | 23 + .../backend/test/factories/execution-step.js | 15 + packages/backend/test/factories/execution.js | 11 + packages/backend/test/factories/flow.js | 13 + packages/backend/test/factories/folder.js | 12 + packages/backend/test/factories/identity.js | 15 + .../backend/test/factories/oauth-client.js | 21 + packages/backend/test/factories/permission.js | 13 + .../backend/test/factories/role-mapping.js | 15 + packages/backend/test/factories/role.js | 18 + .../test/factories/saml-auth-provider.ee.js | 33 + packages/backend/test/factories/step.js | 30 + .../backend/test/factories/subscription.js | 21 + packages/backend/test/factories/usage-data.js | 15 + packages/backend/test/factories/user.js | 14 + .../rest/api/v1/admin/apps/create-config.js | 18 + .../api/v1/admin/apps/create-oauth-client.js | 17 + .../api/v1/admin/apps/get-oauth-client.js | 18 + .../api/v1/admin/apps/get-oauth-clients.js | 18 + .../api/v1/admin/apps/update-oauth-client.js | 18 + .../mocks/rest/api/v1/admin/config/update.js | 26 + .../permissions/get-permissions-catalog.ee.js | 64 + .../rest/api/v1/admin/roles/create-role.ee.js | 32 + .../rest/api/v1/admin/roles/get-role.ee.js | 32 + .../rest/api/v1/admin/roles/get-roles.ee.js | 25 + .../rest/api/v1/admin/roles/update-role.ee.js | 32 + .../create-saml-auth-provider.ee.js | 29 + .../get-role-mappings.ee.js | 23 + .../get-saml-auth-provider.ee.js | 29 + .../get-saml-auth-providers.ee.js | 31 + .../update-role-mappings.ee.js | 23 + .../rest/api/v1/admin/users/create-user.js | 30 + .../mocks/rest/api/v1/admin/users/get-user.js | 30 + .../rest/api/v1/admin/users/get-users.js | 38 + .../rest/api/v1/admin/users/update-user.js | 28 + .../rest/api/v1/apps/create-connection.js | 24 + .../rest/api/v1/apps/get-action-substeps.js | 14 + .../mocks/rest/api/v1/apps/get-actions.js | 22 + .../test/mocks/rest/api/v1/apps/get-app.js | 22 + .../test/mocks/rest/api/v1/apps/get-apps.js | 24 + .../test/mocks/rest/api/v1/apps/get-auth.js | 20 + .../test/mocks/rest/api/v1/apps/get-config.js | 20 + .../mocks/rest/api/v1/apps/get-connections.js | 24 + .../rest/api/v1/apps/get-oauth-client.js | 18 + .../rest/api/v1/apps/get-oauth-clients.js | 18 + .../rest/api/v1/apps/get-trigger-substeps.js | 14 + .../mocks/rest/api/v1/apps/get-triggers.js | 25 + .../mocks/rest/api/v1/automatisch/config.js | 29 + .../mocks/rest/api/v1/automatisch/info.js | 20 + .../mocks/rest/api/v1/automatisch/license.js | 19 + .../api/v1/connections/reset-connection.js | 26 + .../api/v1/connections/update-connection.js | 26 + .../api/v1/executions/get-execution-steps.js | 40 + .../rest/api/v1/executions/get-execution.js | 41 + .../rest/api/v1/executions/get-executions.js | 42 + .../mocks/rest/api/v1/flows/create-flow.js | 23 + .../mocks/rest/api/v1/flows/create-step.js | 26 + .../mocks/rest/api/v1/flows/duplicate-flow.js | 38 + .../mocks/rest/api/v1/flows/export-flow.js | 41 + .../test/mocks/rest/api/v1/flows/get-flow.js | 38 + .../test/mocks/rest/api/v1/flows/get-flows.js | 39 + .../mocks/rest/api/v1/flows/import-flow.js | 35 + .../rest/api/v1/flows/update-flow-folder.js | 29 + .../rest/api/v1/flows/update-flow-status.js | 38 + .../rest/api/v1/folders/create-folder.js | 21 + .../mocks/rest/api/v1/folders/get-folders.js | 23 + .../rest/api/v1/folders/update-folder.js | 21 + .../rest/api/v1/payment/get-paddle-info.js | 17 + .../mocks/rest/api/v1/payment/get-plans.js | 22 + .../get-saml-auth-providers.js | 23 + .../api/v1/steps/create-dynamic-fields.js | 36 + .../mocks/rest/api/v1/steps/get-connection.js | 26 + .../rest/api/v1/steps/get-previous-steps.js | 42 + .../test/mocks/rest/api/v1/steps/test-step.js | 34 + .../mocks/rest/api/v1/steps/update-step.js | 27 + .../mocks/rest/api/v1/users/create-user.js | 29 + .../test/mocks/rest/api/v1/users/get-apps.js | 55 + .../rest/api/v1/users/get-current-user.js | 39 + .../rest/api/v1/users/get-invoices.ee.js | 14 + .../rest/api/v1/users/get-subscription.js | 27 + .../mocks/rest/api/v1/users/get-user-trial.js | 17 + .../rest/api/v1/users/register-user.ee.js | 29 + .../v1/users/update-current-user-password.js | 22 + .../rest/api/v1/users/update-current-user.js | 21 + packages/backend/test/setup/check-env-file.js | 12 + packages/backend/test/setup/global-hooks.js | 35 + .../backend/test/setup/insert-assertions.js | 8 + .../backend/test/setup/prepare-test-env.js | 26 + packages/backend/vitest.config.js | 38 + packages/backend/yarn.lock | 4805 +++++++ packages/docs/.gitignore | 1 + packages/docs/package.json | 32 + packages/docs/pages/.vitepress/config.js | 858 ++ .../pages/.vitepress/theme/CustomLayout.vue | 51 + .../docs/pages/.vitepress/theme/custom.css | 149 + packages/docs/pages/.vitepress/theme/index.js | 8 + packages/docs/pages/advanced/configuration.md | 47 + packages/docs/pages/advanced/credentials.md | 9 + packages/docs/pages/advanced/telemetry.md | 33 + packages/docs/pages/apps/airtable/actions.md | 14 + .../docs/pages/apps/airtable/connection.md | 19 + packages/docs/pages/apps/anthropic/actions.md | 12 + .../docs/pages/apps/anthropic/connection.md | 8 + .../docs/pages/apps/appwrite/connection.md | 20 + packages/docs/pages/apps/appwrite/triggers.md | 12 + .../docs/pages/apps/brave-search/actions.md | 12 + .../pages/apps/brave-search/connection.md | 8 + packages/docs/pages/apps/carbone/actions.md | 12 + .../docs/pages/apps/carbone/connection.md | 10 + packages/docs/pages/apps/clickup/actions.md | 18 + .../docs/pages/apps/clickup/connection.md | 17 + packages/docs/pages/apps/clickup/triggers.md | 18 + .../docs/pages/apps/cryptography/actions.md | 14 + .../pages/apps/cryptography/connection.md | 3 + packages/docs/pages/apps/datastore/actions.md | 14 + .../docs/pages/apps/datastore/connection.md | 3 + packages/docs/pages/apps/deepl/actions.md | 12 + packages/docs/pages/apps/deepl/connection.md | 8 + packages/docs/pages/apps/delay/actions.md | 14 + packages/docs/pages/apps/delay/connection.md | 3 + packages/docs/pages/apps/discord/actions.md | 14 + .../docs/pages/apps/discord/connection.md | 26 + packages/docs/pages/apps/disqus/connection.md | 19 + packages/docs/pages/apps/disqus/triggers.md | 14 + packages/docs/pages/apps/dropbox/actions.md | 14 + .../docs/pages/apps/dropbox/connection.md | 20 + packages/docs/pages/apps/filter/actions.md | 12 + packages/docs/pages/apps/filter/connection.md | 12 + packages/docs/pages/apps/flickr/connection.md | 22 + packages/docs/pages/apps/flickr/triggers.md | 18 + packages/docs/pages/apps/formatter/actions.md | 16 + .../docs/pages/apps/formatter/connection.md | 26 + .../docs/pages/apps/freescout/connection.md | 13 + .../docs/pages/apps/freescout/triggers.md | 13 + packages/docs/pages/apps/ghost/connection.md | 13 + packages/docs/pages/apps/ghost/triggers.md | 12 + packages/docs/pages/apps/github/actions.md | 12 + packages/docs/pages/apps/github/connection.md | 15 + packages/docs/pages/apps/github/triggers.md | 18 + packages/docs/pages/apps/gitlab/connection.md | 18 + packages/docs/pages/apps/gitlab/triggers.md | 36 + .../pages/apps/google-calendar/connection.md | 28 + .../pages/apps/google-calendar/triggers.md | 14 + .../pages/apps/google-drive/connection.md | 28 + .../docs/pages/apps/google-drive/triggers.md | 18 + .../pages/apps/google-forms/connection.md | 28 + .../docs/pages/apps/google-forms/triggers.md | 12 + .../docs/pages/apps/google-sheets/actions.md | 18 + .../pages/apps/google-sheets/connection.md | 28 + .../docs/pages/apps/google-sheets/triggers.md | 16 + .../docs/pages/apps/google-tasks/actions.md | 18 + .../pages/apps/google-tasks/connection.md | 28 + .../docs/pages/apps/google-tasks/triggers.md | 16 + .../docs/pages/apps/http-request/actions.md | 12 + .../pages/apps/http-request/connection.md | 3 + packages/docs/pages/apps/hubspot/actions.md | 12 + .../docs/pages/apps/hubspot/connection.md | 22 + .../docs/pages/apps/invoice-ninja/actions.md | 18 + .../pages/apps/invoice-ninja/connection.md | 16 + .../docs/pages/apps/invoice-ninja/triggers.md | 22 + .../docs/pages/apps/jotform/connection.md | 15 + packages/docs/pages/apps/jotform/triggers.md | 12 + packages/docs/pages/apps/mailchimp/actions.md | 14 + .../docs/pages/apps/mailchimp/connection.md | 17 + .../docs/pages/apps/mailchimp/triggers.md | 16 + .../docs/pages/apps/mailerlite/connection.md | 15 + .../docs/pages/apps/mailerlite/triggers.md | 18 + .../docs/pages/apps/mattermost/actions.md | 12 + .../docs/pages/apps/mattermost/connection.md | 19 + packages/docs/pages/apps/miro/actions.md | 16 + packages/docs/pages/apps/miro/connection.md | 19 + .../docs/pages/apps/mistral-ai/actions.md | 12 + .../docs/pages/apps/mistral-ai/connection.md | 8 + packages/docs/pages/apps/notion/actions.md | 16 + packages/docs/pages/apps/notion/connection.md | 22 + packages/docs/pages/apps/notion/triggers.md | 14 + packages/docs/pages/apps/ntfy/actions.md | 12 + packages/docs/pages/apps/ntfy/connection.md | 10 + packages/docs/pages/apps/odoo/actions.md | 12 + packages/docs/pages/apps/odoo/connection.md | 16 + packages/docs/pages/apps/openai/actions.md | 14 + packages/docs/pages/apps/openai/connection.md | 8 + .../docs/pages/apps/openrouter/actions.md | 12 + .../docs/pages/apps/openrouter/connection.md | 8 + .../docs/pages/apps/perplexity/actions.md | 12 + .../docs/pages/apps/perplexity/connection.md | 8 + packages/docs/pages/apps/pipedrive/actions.md | 22 + .../docs/pages/apps/pipedrive/connection.md | 17 + .../docs/pages/apps/pipedrive/triggers.md | 18 + .../docs/pages/apps/placetel/connection.md | 7 + packages/docs/pages/apps/placetel/triggers.md | 12 + .../docs/pages/apps/postgresql/actions.md | 18 + .../docs/pages/apps/postgresql/connection.md | 19 + packages/docs/pages/apps/pushover/actions.md | 12 + .../docs/pages/apps/pushover/connection.md | 9 + packages/docs/pages/apps/reddit/actions.md | 12 + packages/docs/pages/apps/reddit/connection.md | 15 + packages/docs/pages/apps/reddit/triggers.md | 12 + packages/docs/pages/apps/removebg/actions.md | 12 + .../docs/pages/apps/removebg/connection.md | 11 + packages/docs/pages/apps/rss/connection.md | 3 + packages/docs/pages/apps/rss/triggers.md | 12 + .../docs/pages/apps/salesforce/actions.md | 18 + .../docs/pages/apps/salesforce/connection.md | 25 + .../docs/pages/apps/salesforce/triggers.md | 12 + .../docs/pages/apps/scheduler/connection.md | 3 + .../docs/pages/apps/scheduler/triggers.md | 20 + .../docs/pages/apps/signalwire/actions.md | 12 + .../docs/pages/apps/signalwire/connection.md | 16 + .../docs/pages/apps/signalwire/triggers.md | 14 + packages/docs/pages/apps/slack/actions.md | 18 + packages/docs/pages/apps/slack/connection.md | 25 + packages/docs/pages/apps/smtp/actions.md | 12 + packages/docs/pages/apps/smtp/connection.md | 16 + packages/docs/pages/apps/spotify/actions.md | 12 + .../docs/pages/apps/spotify/connection.md | 20 + packages/docs/pages/apps/strava/actions.md | 12 + packages/docs/pages/apps/strava/connection.md | 14 + packages/docs/pages/apps/stripe/connection.md | 14 + packages/docs/pages/apps/stripe/triggers.md | 18 + .../docs/pages/apps/telegram-bot/actions.md | 12 + .../pages/apps/telegram-bot/connection.md | 14 + packages/docs/pages/apps/todoist/actions.md | 12 + .../docs/pages/apps/todoist/connection.md | 14 + packages/docs/pages/apps/todoist/triggers.md | 12 + .../docs/pages/apps/together-ai/actions.md | 14 + .../docs/pages/apps/together-ai/connection.md | 8 + packages/docs/pages/apps/trello/actions.md | 12 + packages/docs/pages/apps/trello/connection.md | 16 + packages/docs/pages/apps/twilio/actions.md | 12 + packages/docs/pages/apps/twilio/connection.md | 13 + packages/docs/pages/apps/twilio/triggers.md | 12 + packages/docs/pages/apps/twitter/actions.md | 14 + .../docs/pages/apps/twitter/connection.md | 25 + packages/docs/pages/apps/twitter/triggers.md | 18 + .../docs/pages/apps/typeform/connection.md | 14 + packages/docs/pages/apps/typeform/triggers.md | 12 + packages/docs/pages/apps/virtualq/actions.md | 18 + .../docs/pages/apps/virtualq/connection.md | 13 + .../docs/pages/apps/vtiger-crm/actions.md | 20 + .../docs/pages/apps/vtiger-crm/connection.md | 13 + .../docs/pages/apps/vtiger-crm/triggers.md | 22 + .../docs/pages/apps/webhooks/connection.md | 7 + packages/docs/pages/apps/webhooks/triggers.md | 12 + .../docs/pages/apps/wordpress/connection.md | 9 + .../docs/pages/apps/wordpress/triggers.md | 16 + packages/docs/pages/apps/xero/connection.md | 18 + packages/docs/pages/apps/xero/triggers.md | 14 + .../apps/you-need-a-budget/connection.md | 19 + .../pages/apps/you-need-a-budget/triggers.md | 18 + .../docs/pages/apps/youtube/connection.md | 28 + packages/docs/pages/apps/youtube/triggers.md | 14 + packages/docs/pages/apps/zendesk/actions.md | 22 + .../docs/pages/apps/zendesk/connection.md | 21 + packages/docs/pages/apps/zendesk/triggers.md | 14 + packages/docs/pages/assets/flow-900.png | Bin 0 -> 73440 bytes .../docs/pages/build-integrations/actions.md | 127 + packages/docs/pages/build-integrations/app.md | 77 + .../docs/pages/build-integrations/auth.md | 201 + .../docs/pages/build-integrations/examples.md | 80 + .../build-integrations/folder-structure.md | 68 + .../build-integrations/global-variable.md | 106 + .../docs/pages/build-integrations/triggers.md | 157 + .../docs/pages/components/CustomListing.vue | 24 + .../pages/contributing/contribution-guide.md | 25 + .../pages/contributing/development-setup.md | 101 + .../contributing/repository-structure.md | 19 + packages/docs/pages/guide/available-apps.md | 74 + packages/docs/pages/guide/create-flow.md | 53 + packages/docs/pages/guide/installation.md | 113 + packages/docs/pages/guide/key-concepts.md | 28 + .../docs/pages/guide/request-integration.md | 15 + packages/docs/pages/index.md | 43 + packages/docs/pages/other/community.md | 7 + packages/docs/pages/other/license.md | 11 + .../docs/pages/public/example-app/cat.svg | 34 + .../docs/pages/public/favicons/airtable.svg | 9 + .../docs/pages/public/favicons/anthropic.svg | 8 + .../docs/pages/public/favicons/appwrite.svg | 1 + .../pages/public/favicons/brave-search.svg | 5 + .../docs/pages/public/favicons/carbone.svg | 444 + .../docs/pages/public/favicons/clickup.svg | 27 + .../pages/public/favicons/cryptography.svg | 3 + .../docs/pages/public/favicons/datastore.svg | 13 + packages/docs/pages/public/favicons/deepl.svg | 39 + packages/docs/pages/public/favicons/delay.svg | 7 + .../docs/pages/public/favicons/discord.svg | 4 + .../docs/pages/public/favicons/disqus.svg | 16 + .../docs/pages/public/favicons/dropbox.svg | 3 + .../docs/pages/public/favicons/filter.svg | 8 + .../docs/pages/public/favicons/flickr.svg | 5 + .../docs/pages/public/favicons/formatter.svg | 3 + .../docs/pages/public/favicons/freescout.svg | 24 + packages/docs/pages/public/favicons/ghost.svg | 60 + .../docs/pages/public/favicons/github.svg | 6 + .../docs/pages/public/favicons/gitlab.svg | 1 + .../pages/public/favicons/google-calendar.svg | 27 + .../pages/public/favicons/google-drive.svg | 8 + .../pages/public/favicons/google-forms.svg | 41 + .../pages/public/favicons/google-sheets.svg | 89 + .../pages/public/favicons/google-tasks.svg | 16 + .../pages/public/favicons/http-request.svg | 1 + .../docs/pages/public/favicons/hubspot.svg | 8 + .../pages/public/favicons/invoice-ninja.svg | 2 + .../docs/pages/public/favicons/jotform.svg | 1 + .../docs/pages/public/favicons/mailchimp.svg | 1 + .../docs/pages/public/favicons/mailerlite.svg | 1 + .../docs/pages/public/favicons/mattermost.svg | 6 + packages/docs/pages/public/favicons/miro.svg | 1 + .../docs/pages/public/favicons/mistral-ai.svg | 32 + .../docs/pages/public/favicons/notion.svg | 7 + packages/docs/pages/public/favicons/ntfy.svg | 1 + packages/docs/pages/public/favicons/odoo.svg | 1 + .../docs/pages/public/favicons/openai.svg | 6 + .../docs/pages/public/favicons/openrouter.svg | 1 + .../docs/pages/public/favicons/perplexity.svg | 1 + .../docs/pages/public/favicons/pipedrive.svg | 1 + .../docs/pages/public/favicons/placetel.svg | 6 + .../docs/pages/public/favicons/postgres.svg | 10 + .../docs/pages/public/favicons/pushover.svg | 7 + .../docs/pages/public/favicons/reddit.svg | 1 + .../docs/pages/public/favicons/removebg.svg | 1 + packages/docs/pages/public/favicons/rss.svg | 6 + .../docs/pages/public/favicons/salesforce.svg | 16 + .../docs/pages/public/favicons/scheduler.svg | 1 + .../docs/pages/public/favicons/signalwire.svg | 1 + packages/docs/pages/public/favicons/slack.svg | 6 + packages/docs/pages/public/favicons/smtp.svg | 4 + .../docs/pages/public/favicons/spotify.svg | 6 + .../docs/pages/public/favicons/strava.svg | 6 + .../docs/pages/public/favicons/stripe.svg | 10 + .../pages/public/favicons/telegram-bot.svg | 14 + .../docs/pages/public/favicons/todoist.svg | 14 + .../pages/public/favicons/together-ai.svg | 1 + .../docs/pages/public/favicons/trello.svg | 1 + .../docs/pages/public/favicons/twilio.svg | 11 + .../docs/pages/public/favicons/twitter.svg | 4 + .../docs/pages/public/favicons/typeform.svg | 4 + .../docs/pages/public/favicons/virtualq.svg | 35 + .../docs/pages/public/favicons/vtiger-crm.svg | 925 ++ .../docs/pages/public/favicons/webhooks.svg | 8 + .../docs/pages/public/favicons/wordpress.svg | 6 + packages/docs/pages/public/favicons/xero.svg | 1 + .../public/favicons/you-need-a-budget.svg | 25 + .../docs/pages/public/favicons/youtube.svg | 4 + .../docs/pages/public/favicons/zendesk.svg | 1 + packages/docs/yarn.lock | 1192 ++ packages/e2e-tests/.env-example | 6 + packages/e2e-tests/.eslintignore | 6 + packages/e2e-tests/.eslintrc.json | 25 + packages/e2e-tests/.gitignore | 5 + packages/e2e-tests/README.md | 59 + .../fixtures/accept-invitation-page.js | 46 + .../e2e-tests/fixtures/admin-setup-page.js | 80 + .../admin/application-oauth-clients-page.js | 48 + .../admin/application-settings-page.js | 52 + .../fixtures/admin/applications-page.js | 32 + .../fixtures/admin/create-role-page.js | 114 + .../fixtures/admin/create-user-page.js | 43 + .../fixtures/admin/delete-role-modal.js | 20 + .../fixtures/admin/delete-user-modal.js | 19 + .../fixtures/admin/edit-role-page.js | 10 + .../fixtures/admin/edit-user-page.js | 39 + packages/e2e-tests/fixtures/admin/index.js | 43 + .../fixtures/admin/role-conditions-modal.js | 47 + .../e2e-tests/fixtures/admin/roles-page.js | 81 + .../e2e-tests/fixtures/admin/users-page.js | 136 + .../e2e-tests/fixtures/applications-modal.js | 34 + .../e2e-tests/fixtures/applications-page.js | 21 + .../github/add-github-connection-modal.js | 49 + .../fixtures/apps/github/github-page.js | 66 + .../fixtures/apps/github/github-popup.js | 93 + .../add-mattermost-connection-modal.js | 25 + .../e2e-tests/fixtures/authenticated-page.js | 27 + packages/e2e-tests/fixtures/base-page.js | 96 + .../e2e-tests/fixtures/connections-page.js | 9 + .../e2e-tests/fixtures/executions-page.js | 5 + .../e2e-tests/fixtures/flow-editor-page.js | 91 + packages/e2e-tests/fixtures/index.js | 74 + packages/e2e-tests/fixtures/login-page.js | 46 + .../e2e-tests/fixtures/my-profile-page.js | 23 + .../e2e-tests/fixtures/postgres-config.js | 14 + .../e2e-tests/fixtures/user-interface-page.js | 52 + packages/e2e-tests/helpers/auth-api-helper.js | 16 + packages/e2e-tests/helpers/db-helpers.js | 32 + packages/e2e-tests/helpers/flow-api-helper.js | 69 + packages/e2e-tests/helpers/user-api-helper.js | 24 + packages/e2e-tests/knexfile.js | 27 + .../e2e-tests/license-server-with-mock.js | 28 + packages/e2e-tests/package.json | 44 + packages/e2e-tests/playwright.config.js | 102 + .../tests/admin-setup/admin.setup.js | 29 + .../tests/admin/applications.spec.js | 405 + .../tests/admin/manage-roles.spec.js | 409 + .../tests/admin/manage-users.spec.js | 216 + .../tests/admin/role-conditions.spec.js | 69 + .../tests/app-integrations/github.spec.js | 63 + .../tests/app-integrations/webhook.spec.js | 95 + .../e2e-tests/tests/apps/list-apps.spec.js | 91 + .../tests/authentication/login.spec.js | 21 + .../connections/create-connection.spec.js | 49 + .../enabled-pop-up-reminder.spec.js | 103 + .../executions/display-execution.spec.js | 37 + .../tests/executions/list-executions.spec.js | 17 + .../tests/flow-editor/create-flow.spec.js | 197 + packages/e2e-tests/tests/global.teardown.js | 12 + .../tests/my-profile/profile-updates.spec.js | 169 + .../user-interface-configuration.spec.js | 169 + .../tests/user-invitation/invitation.spec.js | 84 + packages/e2e-tests/yarn.lock | 1145 ++ packages/web/.env-example | 4 + packages/web/.eslintignore | 4 + packages/web/.eslintrc.js | 10 + packages/web/.gitignore | 23 + packages/web/README.md | 46 + packages/web/index.js | 0 packages/web/jsconfig.json | 6 + packages/web/package.json | 98 + packages/web/public/browser-tab.ico | Bin 0 -> 15406 bytes packages/web/public/fonts/Inter-Bold.ttf | Bin 0 -> 316584 bytes packages/web/public/fonts/Inter-Medium.ttf | Bin 0 -> 315132 bytes packages/web/public/fonts/Inter-Regular.ttf | Bin 0 -> 310252 bytes packages/web/public/index.html | 82 + packages/web/public/robots.txt | 3 + packages/web/src/adminSettingsRoutes.jsx | 117 + .../components/AcceptInvitationForm/index.jsx | 138 + .../components/AccountDropdownMenu/index.jsx | 77 + .../src/components/AddAppConnection/index.jsx | 191 + .../src/components/AddAppConnection/style.js | 8 + .../components/AddNewAppConnection/index.jsx | 149 + .../index.jsx | 103 + .../index.jsx | 101 + .../style.js | 8 + .../index.jsx | 148 + .../style.js | 8 + .../AdminApplicationOAuthClients/index.jsx | 92 + .../AdminApplicationSettings/index.jsx | 108 + .../AdminApplicationSettings/style.js | 6 + .../index.jsx | 124 + .../AdminSettingsLayout/Footer/index.jsx | 35 + .../components/AdminSettingsLayout/index.jsx | 127 + packages/web/src/components/AppBar/index.jsx | 90 + packages/web/src/components/AppBar/style.js | 8 + .../AppConnectionContextMenu/index.jsx | 107 + .../src/components/AppConnectionRow/index.jsx | 189 + .../src/components/AppConnectionRow/style.js | 14 + .../src/components/AppConnections/index.jsx | 45 + .../web/src/components/AppFlows/index.jsx | 83 + packages/web/src/components/AppIcon/index.jsx | 43 + packages/web/src/components/AppRow/index.jsx | 78 + packages/web/src/components/AppRow/style.js | 22 + packages/web/src/components/Can/index.jsx | 18 + .../CheckoutCompletedAlert/index.ee.jsx | 29 + .../ChooseAppAndEventSubstep/index.jsx | 278 + .../ChooseConnectionSubstep/index.jsx | 320 + .../web/src/components/CodeEditor/index.jsx | 104 + .../web/src/components/CodeEditor/style.js | 9 + .../ColorInput/ColorButton/index.jsx | 38 + .../ColorInput/ColorButton/style.jsx | 14 + .../web/src/components/ColorInput/index.jsx | 45 + .../ConditionalIconButton/index.jsx | 36 + .../components/ConditionalIconButton/style.js | 12 + .../components/ConfirmationDialog/index.jsx | 70 + .../web/src/components/Container/index.jsx | 19 + .../ControlledAutocomplete/index.jsx | 143 + .../components/ControlledCheckbox/index.jsx | 68 + .../Controller.jsx | 34 + .../CustomOptions.jsx | 98 + .../ControlledCustomAutocomplete/Options.jsx | 132 + .../ControlledCustomAutocomplete/index.jsx | 303 + .../ControlledCustomAutocomplete/style.js | 15 + .../src/components/CustomLogo/index.ee.jsx | 20 + .../web/src/components/CustomLogo/style.ee.js | 6 + .../web/src/components/DefaultLogo/index.jsx | 21 + .../DeleteAccountDialog/index.ee.jsx | 48 + .../components/DeleteRoleButton/index.ee.jsx | 89 + .../components/DeleteUserButton/index.ee.jsx | 79 + packages/web/src/components/Drawer/index.jsx | 92 + packages/web/src/components/Drawer/style.js | 48 + .../DynamicField/DynamicFieldEntry.jsx | 50 + .../web/src/components/DynamicField/index.jsx | 146 + .../components/EditableTypography/index.jsx | 106 + .../components/EditableTypography/style.js | 27 + packages/web/src/components/Editor/index.jsx | 105 + .../web/src/components/EditorLayout/index.jsx | 181 + .../web/src/components/EditorLayout/style.js | 10 + .../src/components/EditorNew/Edge/Edge.jsx | 57 + .../src/components/EditorNew/EditorNew.jsx | 278 + .../EditorNew/FlowStepNode/FlowStepNode.jsx | 60 + .../EditorNew/FlowStepNode/style.js | 14 + .../EditorNew/InvisibleNode/InvisibleNode.jsx | 19 + .../web/src/components/EditorNew/constants.js | 10 + .../web/src/components/EditorNew/style.js | 13 + .../src/components/EditorNew/useAutoLayout.js | 69 + .../EditorNew/useScrollBoundaries.js | 33 + .../web/src/components/EditorNew/utils.js | 88 + .../src/components/ExecutionHeader/index.jsx | 89 + .../web/src/components/ExecutionRow/index.jsx | 77 + .../web/src/components/ExecutionRow/style.js | 46 + .../src/components/ExecutionStep/index.jsx | 192 + .../web/src/components/ExecutionStep/style.js | 54 + .../src/components/FileUploadInput/index.js | 38 + .../web/src/components/FlowAppIcons/index.jsx | 45 + .../src/components/FlowContextMenu/index.jsx | 147 + packages/web/src/components/FlowRow/index.jsx | 135 + packages/web/src/components/FlowRow/style.js | 46 + .../web/src/components/FlowStep/index.jsx | 398 + packages/web/src/components/FlowStep/style.js | 35 + .../components/FlowStepContextMenu/index.jsx | 55 + .../FlowSubstep/FilterConditions/index.jsx | 234 + .../web/src/components/FlowSubstep/index.jsx | 90 + .../src/components/FlowSubstepTitle/index.jsx | 37 + .../src/components/FlowSubstepTitle/style.jsx | 13 + .../ForgotPasswordForm/index.ee.jsx | 76 + packages/web/src/components/Form/index.jsx | 116 + .../web/src/components/HideOnScroll/index.jsx | 9 + .../src/components/ImportFlowDialog/index.jsx | 160 + .../web/src/components/InputCreator/index.jsx | 246 + .../src/components/InstallationForm/index.jsx | 206 + .../IntermediateStepCount/index.jsx | 23 + .../components/IntermediateStepCount/style.js | 11 + .../web/src/components/IntlProvider/index.jsx | 22 + .../web/src/components/Invoices/index.ee.jsx | 97 + .../web/src/components/JSONViewer/index.jsx | 60 + .../web/src/components/JSONViewer/style.jsx | 31 + packages/web/src/components/Layout/index.jsx | 154 + .../web/src/components/ListItemLink/index.jsx | 64 + .../web/src/components/ListLoader/index.jsx | 50 + .../web/src/components/LoginForm/index.jsx | 136 + packages/web/src/components/Logo/index.jsx | 19 + packages/web/src/components/Logo/style.js | 7 + .../MationLogo/assets/mation-logo.svg | 3 + .../web/src/components/MationLogo/index.jsx | 8 + .../src/components/MetadataProvider/index.jsx | 40 + .../src/components/NoResultFound/index.jsx | 40 + .../web/src/components/NoResultFound/style.js | 10 + .../web/src/components/NotFound/index.jsx | 42 + .../src/components/NotificationCard/index.jsx | 52 + .../OAuthClientsDialog/index.ee.jsx | 43 + .../web/src/components/PageTitle/index.jsx | 6 + .../PermissionCatalogField/ActionField.jsx | 51 + .../PermissionCatalogFieldLoader/index.jsx | 62 + .../PermissionSettings.ee.jsx | 161 + .../PermissionCatalogField/index.ee.jsx | 126 + packages/web/src/components/Portal/index.jsx | 9 + .../web/src/components/PowerInput/Popper.jsx | 51 + .../components/PowerInput/SuggestionItem.jsx | 49 + .../src/components/PowerInput/Suggestions.jsx | 185 + .../web/src/components/PowerInput/data.js | 93 + .../web/src/components/PowerInput/index.jsx | 168 + .../web/src/components/PowerInput/style.js | 64 + .../web/src/components/PublicLayout/index.jsx | 38 + .../components/QueryClientProvider/index.jsx | 38 + .../components/ResetPasswordForm/index.ee.jsx | 148 + .../web/src/components/RoleList/index.ee.jsx | 103 + packages/web/src/components/Router/index.jsx | 2 + .../web/src/components/SearchInput/index.jsx | 39 + .../components/SearchableJSONViewer/index.jsx | 50 + .../src/components/SettingsLayout/index.jsx | 83 + .../src/components/SignUpForm/index.ee.jsx | 191 + packages/web/src/components/Slate/Element.jsx | 22 + .../web/src/components/Slate/Variable.jsx | 41 + packages/web/src/components/Slate/index.jsx | 3 + packages/web/src/components/Slate/types.js | 1 + packages/web/src/components/Slate/utils.js | 234 + .../src/components/SnackbarProvider/index.jsx | 17 + .../web/src/components/SplitButton/index.jsx | 113 + .../src/components/SsoProviders/index.ee.jsx | 41 + .../SubscriptionCancelledAlert/index.ee.jsx | 40 + packages/web/src/components/Switch/index.jsx | 84 + .../web/src/components/TabPanel/index.jsx | 17 + .../web/src/components/TestSubstep/index.jsx | 154 + .../web/src/components/TextField/index.jsx | 98 + .../src/components/ThemeProvider/index.jsx | 76 + .../components/TrialOverAlert/index.ee.jsx | 31 + .../components/TrialStatusBadge/index.ee.jsx | 24 + .../components/TrialStatusBadge/style.ee.jsx | 13 + .../components/UpgradeFreeTrial/index.ee.jsx | 179 + .../UsageDataInformation/index.ee.jsx | 270 + .../UserList/TablePaginationActions/index.jsx | 83 + .../web/src/components/UserList/index.jsx | 158 + packages/web/src/components/UserList/style.js | 11 + .../src/components/WebhookUrlInfo/index.jsx | 45 + .../src/components/WebhookUrlInfo/style.js | 13 + packages/web/src/config/app.js | 23 + packages/web/src/config/urls.js | 106 + packages/web/src/contexts/Authentication.jsx | 43 + packages/web/src/contexts/Editor.jsx | 21 + packages/web/src/contexts/FieldEntry.jsx | 19 + packages/web/src/contexts/Paddle.ee.jsx | 107 + packages/web/src/contexts/StepExecutions.jsx | 20 + packages/web/src/helpers/api.js | 34 + .../web/src/helpers/authenticationSteps.js | 62 + .../src/helpers/computeAuthStepVariables.js | 35 + .../web/src/helpers/computePermissions.ee.js | 80 + packages/web/src/helpers/computeVariables.js | 22 + packages/web/src/helpers/copyInputValue.js | 4 + packages/web/src/helpers/errors.js | 38 + packages/web/src/helpers/filterObject.js | 44 + packages/web/src/helpers/isEmpty.js | 12 + packages/web/src/helpers/nestObject.js | 12 + packages/web/src/helpers/storage.js | 14 + .../web/src/helpers/translationValues.jsx | 14 + packages/web/src/helpers/userAbility.js | 19 + packages/web/src/hooks/useAcceptInvitation.js | 15 + packages/web/src/hooks/useActionSubsteps.js | 22 + packages/web/src/hooks/useActions.js | 19 + .../web/src/hooks/useAdminCreateAppConfig.js | 21 + .../src/hooks/useAdminCreateOAuthClient.ee.js | 24 + packages/web/src/hooks/useAdminCreateRole.js | 21 + .../hooks/useAdminCreateSamlAuthProvider.js | 21 + packages/web/src/hooks/useAdminCreateUser.js | 21 + packages/web/src/hooks/useAdminDeleteRole.js | 21 + .../web/src/hooks/useAdminOAuthClient.ee.js | 22 + .../web/src/hooks/useAdminOAuthClients.js | 17 + .../useAdminSamlAuthProviderRoleMappings.js | 24 + .../src/hooks/useAdminSamlAuthProviders.ee.js | 18 + .../web/src/hooks/useAdminUpdateAppConfig.js | 24 + .../web/src/hooks/useAdminUpdateConfig.js | 21 + .../src/hooks/useAdminUpdateOAuthClient.ee.js | 28 + packages/web/src/hooks/useAdminUpdateRole.js | 21 + .../hooks/useAdminUpdateSamlAuthProvider.js | 24 + ...AdminUpdateSamlAuthProviderRoleMappings.js | 31 + packages/web/src/hooks/useAdminUpdateUser.js | 21 + packages/web/src/hooks/useAdminUser.js | 17 + packages/web/src/hooks/useAdminUserDelete.js | 15 + packages/web/src/hooks/useAdminUsers.js | 17 + packages/web/src/hooks/useApp.js | 19 + packages/web/src/hooks/useAppAuth.js | 19 + packages/web/src/hooks/useAppConfig.ee.js | 17 + packages/web/src/hooks/useAppConnections.js | 19 + packages/web/src/hooks/useAppFlows.js | 22 + packages/web/src/hooks/useApps.js | 27 + .../web/src/hooks/useAuthenticateApp.ee.js | 149 + packages/web/src/hooks/useAuthentication.js | 8 + .../web/src/hooks/useAutomatischConfig.js | 17 + packages/web/src/hooks/useAutomatischInfo.js | 20 + .../src/hooks/useAutomatischNotifications.js | 16 + packages/web/src/hooks/useCloud.js | 17 + packages/web/src/hooks/useConnectionFlows.js | 25 + .../web/src/hooks/useCreateAccessToken.js | 15 + packages/web/src/hooks/useCreateConnection.js | 18 + .../src/hooks/useCreateConnectionAuthUrl.js | 17 + packages/web/src/hooks/useCreateFlow.js | 23 + packages/web/src/hooks/useCreateStep.js | 24 + packages/web/src/hooks/useCurrentUser.js | 18 + .../web/src/hooks/useCurrentUserAbility.js | 8 + packages/web/src/hooks/useDeleteConnection.js | 14 + .../web/src/hooks/useDeleteCurrentUser.js | 14 + packages/web/src/hooks/useDeleteFlow.js | 23 + packages/web/src/hooks/useDeleteStep.js | 14 + packages/web/src/hooks/useDocsUrl.js | 27 + .../web/src/hooks/useDownloadJsonAsFile.js | 31 + packages/web/src/hooks/useDuplicateFlow.js | 23 + packages/web/src/hooks/useDynamicData.js | 128 + packages/web/src/hooks/useDynamicFields.js | 96 + packages/web/src/hooks/useEnqueueSnackbar.js | 18 + packages/web/src/hooks/useExecution.js | 18 + packages/web/src/hooks/useExecutionSteps.js | 29 + packages/web/src/hooks/useExecutions.js | 22 + packages/web/src/hooks/useExportFlow.js | 15 + .../web/src/hooks/useFieldEntryContext.jsx | 8 + packages/web/src/hooks/useFlow.js | 19 + packages/web/src/hooks/useFlows.js | 18 + packages/web/src/hooks/useForgotPassword.js | 15 + packages/web/src/hooks/useFormatMessage.js | 13 + packages/web/src/hooks/useImportFlow.js | 21 + packages/web/src/hooks/useInstallation.js | 15 + packages/web/src/hooks/useInvoices.ee.js | 18 + packages/web/src/hooks/useLazyApps.js | 30 + packages/web/src/hooks/useLicense.js | 15 + packages/web/src/hooks/useOAuthClients.js | 17 + packages/web/src/hooks/usePaddle.ee.js | 8 + packages/web/src/hooks/usePaddleInfo.ee.js | 17 + packages/web/src/hooks/usePaymentPlans.ee.js | 18 + .../web/src/hooks/usePermissionCatalog.ee.js | 18 + packages/web/src/hooks/usePlanAndUsage.js | 19 + packages/web/src/hooks/usePrevious.js | 9 + packages/web/src/hooks/useRegisterUser.js | 19 + packages/web/src/hooks/useResetConnection.js | 15 + packages/web/src/hooks/useResetPassword.js | 15 + .../web/src/hooks/useRevokeAccessToken.js | 15 + packages/web/src/hooks/useRole.ee.js | 19 + packages/web/src/hooks/useRoles.ee.js | 18 + packages/web/src/hooks/useSamlAuthProvider.js | 22 + .../web/src/hooks/useSamlAuthProviders.ee.js | 18 + packages/web/src/hooks/useStepConnection.js | 26 + .../src/hooks/useStepWithTestExecutions.js | 19 + packages/web/src/hooks/useSubscription.ee.js | 55 + packages/web/src/hooks/useTestConnection.js | 19 + packages/web/src/hooks/useTestStep.js | 14 + packages/web/src/hooks/useTriggerSubsteps.js | 22 + packages/web/src/hooks/useTriggers.js | 19 + packages/web/src/hooks/useUpdateConnection.js | 18 + .../web/src/hooks/useUpdateCurrentUser.js | 19 + .../src/hooks/useUpdateCurrentUserPassword.js | 14 + packages/web/src/hooks/useUpdateFlow.js | 21 + packages/web/src/hooks/useUpdateFlowStatus.js | 39 + packages/web/src/hooks/useUpdateStep.js | 29 + packages/web/src/hooks/useUserApps.js | 25 + packages/web/src/hooks/useUserTrial.ee.js | 71 + packages/web/src/hooks/useVerifyConnection.js | 24 + packages/web/src/hooks/useVersion.js | 37 + packages/web/src/index.jsx | 39 + packages/web/src/locales/en.json | 333 + .../web/src/pages/AcceptInvitation/index.jsx | 14 + .../web/src/pages/AdminApplication/index.jsx | 157 + .../web/src/pages/AdminApplications/index.jsx | 59 + packages/web/src/pages/Application/index.jsx | 274 + packages/web/src/pages/Applications/index.jsx | 111 + .../src/pages/Authentication/RoleMappings.jsx | 189 + .../RoleMappingsFieldsArray.jsx | 98 + .../Authentication/SamlConfiguration.jsx | 299 + .../web/src/pages/Authentication/index.jsx | 43 + .../BillingAndUsageSettings/index.ee.jsx | 41 + .../web/src/pages/CreateRole/index.ee.jsx | 189 + packages/web/src/pages/CreateUser/index.jsx | 206 + packages/web/src/pages/Dashboard/index.jsx | 3 + packages/web/src/pages/EditRole/index.ee.jsx | 129 + packages/web/src/pages/EditUser/index.jsx | 216 + packages/web/src/pages/Editor/create.jsx | 48 + packages/web/src/pages/Editor/index.jsx | 5 + packages/web/src/pages/Editor/routes.jsx | 13 + packages/web/src/pages/Execution/index.jsx | 65 + packages/web/src/pages/Executions/index.jsx | 85 + packages/web/src/pages/Flow/index.jsx | 22 + packages/web/src/pages/Flows/index.jsx | 194 + .../web/src/pages/ForgotPassword/index.ee.jsx | 17 + packages/web/src/pages/Installation/index.jsx | 21 + packages/web/src/pages/Login/index.jsx | 18 + .../web/src/pages/LoginCallback/index.jsx | 22 + .../web/src/pages/Notifications/index.jsx | 54 + .../web/src/pages/PlanUpgrade/index.ee.jsx | 34 + .../web/src/pages/ProfileSettings/index.jsx | 307 + .../web/src/pages/ResetPassword/index.ee.jsx | 15 + packages/web/src/pages/Roles/index.ee.jsx | 47 + packages/web/src/pages/SignUp/index.ee.jsx | 15 + .../web/src/pages/UserInterface/index.jsx | 153 + packages/web/src/pages/Users/index.jsx | 47 + packages/web/src/propTypes/propTypes.js | 476 + packages/web/src/reportWebVitals.js | 12 + packages/web/src/routes.jsx | 188 + packages/web/src/settingsRoutes.jsx | 44 + packages/web/src/setupTests.js | 5 + packages/web/src/styles/theme.js | 341 + packages/web/yarn.lock | 11099 ++++++++++++++++ render.yaml | 113 + 2425 files changed, 124537 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/boot.sh create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .dockerignore create mode 100644 .github/workflows/backend.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs-change.yml create mode 100644 .github/workflows/playwright.yml create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 .nvmrc create mode 100644 .prettierrc.js create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .yarnrc create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTOR_LICENSE_AGREEMENT.md create mode 100644 LICENSE create mode 100644 LICENSE.agpl create mode 100644 LICENSE.enterprise create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.compose create mode 100755 docker/compose-entrypoint.sh create mode 100755 docker/entrypoint.sh create mode 100644 packages/backend/.env-example create mode 100644 packages/backend/.env-example.test create mode 100644 packages/backend/.eslintignore create mode 100644 packages/backend/.eslintrc.json create mode 100644 packages/backend/README.md create mode 100644 packages/backend/bin/database/client.js create mode 100644 packages/backend/bin/database/convert-migrations.js create mode 100644 packages/backend/bin/database/create.js create mode 100644 packages/backend/bin/database/drop.js create mode 100644 packages/backend/bin/database/seed-user.js create mode 100644 packages/backend/bin/database/utils.js create mode 100644 packages/backend/knexfile.js create mode 100644 packages/backend/package.json create mode 100644 packages/backend/src/app.js create mode 100644 packages/backend/src/apps/airtable/actions/create-record/index.js create mode 100644 packages/backend/src/apps/airtable/actions/find-record/index.js create mode 100644 packages/backend/src/apps/airtable/actions/index.js create mode 100644 packages/backend/src/apps/airtable/assets/favicon.svg create mode 100644 packages/backend/src/apps/airtable/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/airtable/auth/index.js create mode 100644 packages/backend/src/apps/airtable/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/airtable/auth/refresh-token.js create mode 100644 packages/backend/src/apps/airtable/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/airtable/common/add-auth-header.js create mode 100644 packages/backend/src/apps/airtable/common/auth-scope.js create mode 100644 packages/backend/src/apps/airtable/common/get-current-user.js create mode 100644 packages/backend/src/apps/airtable/dynamic-data/index.js create mode 100644 packages/backend/src/apps/airtable/dynamic-data/list-bases/index.js create mode 100644 packages/backend/src/apps/airtable/dynamic-data/list-table-fields/index.js create mode 100644 packages/backend/src/apps/airtable/dynamic-data/list-table-views/index.js create mode 100644 packages/backend/src/apps/airtable/dynamic-data/list-tables/index.js create mode 100644 packages/backend/src/apps/airtable/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/airtable/dynamic-fields/list-fields/index.js create mode 100644 packages/backend/src/apps/airtable/index.js create mode 100644 packages/backend/src/apps/anthropic/actions/index.js create mode 100644 packages/backend/src/apps/anthropic/actions/send-message/index.js create mode 100644 packages/backend/src/apps/anthropic/assets/favicon.svg create mode 100644 packages/backend/src/apps/anthropic/auth/index.js create mode 100644 packages/backend/src/apps/anthropic/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/anthropic/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js create mode 100644 packages/backend/src/apps/anthropic/common/add-auth-header.js create mode 100644 packages/backend/src/apps/anthropic/dynamic-data/index.js create mode 100644 packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/anthropic/index.js create mode 100644 packages/backend/src/apps/appwrite/assets/favicon.svg create mode 100644 packages/backend/src/apps/appwrite/auth/index.js create mode 100644 packages/backend/src/apps/appwrite/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/appwrite/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/appwrite/common/add-auth-header.js create mode 100644 packages/backend/src/apps/appwrite/common/set-base-url.js create mode 100644 packages/backend/src/apps/appwrite/dynamic-data/index.js create mode 100644 packages/backend/src/apps/appwrite/dynamic-data/list-collections/index.js create mode 100644 packages/backend/src/apps/appwrite/dynamic-data/list-databases/index.js create mode 100644 packages/backend/src/apps/appwrite/index.js create mode 100644 packages/backend/src/apps/appwrite/triggers/index.js create mode 100644 packages/backend/src/apps/appwrite/triggers/new-documents/index.js create mode 100644 packages/backend/src/apps/azure-openai/actions/index.js create mode 100644 packages/backend/src/apps/azure-openai/actions/send-prompt/index.js create mode 100644 packages/backend/src/apps/azure-openai/assets/favicon.svg create mode 100644 packages/backend/src/apps/azure-openai/auth/index.js create mode 100644 packages/backend/src/apps/azure-openai/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/azure-openai/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/azure-openai/common/add-auth-header.js create mode 100644 packages/backend/src/apps/azure-openai/common/set-base-url.js create mode 100644 packages/backend/src/apps/azure-openai/index.js create mode 100644 packages/backend/src/apps/brave-search/actions/index.js create mode 100644 packages/backend/src/apps/brave-search/actions/web-search/index.js create mode 100644 packages/backend/src/apps/brave-search/assets/favicon.svg create mode 100644 packages/backend/src/apps/brave-search/auth/index.js create mode 100644 packages/backend/src/apps/brave-search/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/brave-search/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/brave-search/common/add-accept-header.js create mode 100644 packages/backend/src/apps/brave-search/common/add-auth-header.js create mode 100644 packages/backend/src/apps/brave-search/dynamic-data/index.js create mode 100644 packages/backend/src/apps/brave-search/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/brave-search/index.js create mode 100644 packages/backend/src/apps/carbone/actions/add-template/index.js create mode 100644 packages/backend/src/apps/carbone/actions/index.js create mode 100644 packages/backend/src/apps/carbone/assets/favicon.svg create mode 100644 packages/backend/src/apps/carbone/auth/index.js create mode 100644 packages/backend/src/apps/carbone/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/carbone/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/carbone/common/add-auth-header.js create mode 100644 packages/backend/src/apps/carbone/index.js create mode 100644 packages/backend/src/apps/clickup/actions/create-folder/index.js create mode 100644 packages/backend/src/apps/clickup/actions/create-list/index.js create mode 100644 packages/backend/src/apps/clickup/actions/create-task/index.js create mode 100644 packages/backend/src/apps/clickup/actions/find-task-by-id/index.js create mode 100644 packages/backend/src/apps/clickup/actions/index.js create mode 100644 packages/backend/src/apps/clickup/assets/favicon.svg create mode 100644 packages/backend/src/apps/clickup/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/clickup/auth/index.js create mode 100644 packages/backend/src/apps/clickup/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/clickup/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/clickup/common/add-auth-header.js create mode 100644 packages/backend/src/apps/clickup/common/get-current-user.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-assignees/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-folders/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-lists/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-spaces/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-statuses/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-tags/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-tasks/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-data/list-workspaces/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/clickup/dynamic-fields/use-custom-id/index.js create mode 100644 packages/backend/src/apps/clickup/index.js create mode 100644 packages/backend/src/apps/clickup/triggers/index.js create mode 100644 packages/backend/src/apps/clickup/triggers/new-folders/index.js create mode 100644 packages/backend/src/apps/clickup/triggers/new-lists/index.js create mode 100644 packages/backend/src/apps/clickup/triggers/new-tasks/index.js create mode 100644 packages/backend/src/apps/clickup/triggers/updated-task/index.js create mode 100644 packages/backend/src/apps/code/actions/index.js create mode 100644 packages/backend/src/apps/code/actions/run-javascript/index.js create mode 100644 packages/backend/src/apps/code/assets/favicon.svg create mode 100644 packages/backend/src/apps/code/index.js create mode 100644 packages/backend/src/apps/cryptography/actions/create-hmac/index.js create mode 100644 packages/backend/src/apps/cryptography/actions/create-rsa-sha256-signature/index.js create mode 100644 packages/backend/src/apps/cryptography/actions/index.js create mode 100644 packages/backend/src/apps/cryptography/assets/favicon.svg create mode 100644 packages/backend/src/apps/cryptography/index.js create mode 100644 packages/backend/src/apps/datastore/actions/get-value/index.js create mode 100644 packages/backend/src/apps/datastore/actions/index.js create mode 100644 packages/backend/src/apps/datastore/actions/set-value/index.js create mode 100644 packages/backend/src/apps/datastore/assets/favicon.svg create mode 100644 packages/backend/src/apps/datastore/index.js create mode 100644 packages/backend/src/apps/deepl/actions/index.js create mode 100644 packages/backend/src/apps/deepl/actions/translate-text/index.js create mode 100644 packages/backend/src/apps/deepl/assets/favicon.svg create mode 100644 packages/backend/src/apps/deepl/auth/index.js create mode 100644 packages/backend/src/apps/deepl/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/deepl/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/deepl/common/add-auth-header.js create mode 100644 packages/backend/src/apps/deepl/index.js create mode 100644 packages/backend/src/apps/delay/actions/delay-for/index.js create mode 100644 packages/backend/src/apps/delay/actions/delay-until/index.js create mode 100644 packages/backend/src/apps/delay/actions/index.js create mode 100644 packages/backend/src/apps/delay/assets/favicon.svg create mode 100644 packages/backend/src/apps/delay/index.js create mode 100644 packages/backend/src/apps/discord/actions/create-scheduled-event/index.js create mode 100644 packages/backend/src/apps/discord/actions/index.js create mode 100644 packages/backend/src/apps/discord/actions/send-message-to-channel/index.js create mode 100644 packages/backend/src/apps/discord/assets/favicon.svg create mode 100644 packages/backend/src/apps/discord/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/discord/auth/index.js create mode 100644 packages/backend/src/apps/discord/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/discord/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/discord/common/add-auth-header.js create mode 100644 packages/backend/src/apps/discord/common/get-current-user.js create mode 100644 packages/backend/src/apps/discord/common/scopes.js create mode 100644 packages/backend/src/apps/discord/dynamic-data/index.js create mode 100644 packages/backend/src/apps/discord/dynamic-data/list-channels/index.js create mode 100644 packages/backend/src/apps/discord/dynamic-data/list-voice-channels/index.js create mode 100644 packages/backend/src/apps/discord/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/discord/dynamic-fields/list-external-scheduled-event-fields/index.js create mode 100644 packages/backend/src/apps/discord/index.js create mode 100644 packages/backend/src/apps/discord/triggers/index.js create mode 100644 packages/backend/src/apps/disqus/assets/favicon.svg create mode 100644 packages/backend/src/apps/disqus/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/disqus/auth/index.js create mode 100644 packages/backend/src/apps/disqus/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/disqus/auth/refresh-token.js create mode 100644 packages/backend/src/apps/disqus/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/disqus/common/add-auth-header.js create mode 100644 packages/backend/src/apps/disqus/common/auth-scope.js create mode 100644 packages/backend/src/apps/disqus/common/get-current-user.js create mode 100644 packages/backend/src/apps/disqus/dynamic-data/index.js create mode 100644 packages/backend/src/apps/disqus/dynamic-data/list-forums/index.js create mode 100644 packages/backend/src/apps/disqus/index.js create mode 100644 packages/backend/src/apps/disqus/triggers/index.js create mode 100644 packages/backend/src/apps/disqus/triggers/new-comments/index.js create mode 100644 packages/backend/src/apps/disqus/triggers/new-flagged-comments/index.js create mode 100644 packages/backend/src/apps/dropbox/actions/create-folder/index.js create mode 100644 packages/backend/src/apps/dropbox/actions/index.js create mode 100644 packages/backend/src/apps/dropbox/actions/rename-file/index.js create mode 100644 packages/backend/src/apps/dropbox/assets/favicon.svg create mode 100644 packages/backend/src/apps/dropbox/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/dropbox/auth/index.js create mode 100644 packages/backend/src/apps/dropbox/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/dropbox/auth/refresh-token.js create mode 100644 packages/backend/src/apps/dropbox/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/dropbox/common/add-auth-header.js create mode 100644 packages/backend/src/apps/dropbox/common/get-current-account.js create mode 100644 packages/backend/src/apps/dropbox/common/scopes.js create mode 100644 packages/backend/src/apps/dropbox/index.js create mode 100644 packages/backend/src/apps/filter/actions/continue/index.js create mode 100644 packages/backend/src/apps/filter/actions/index.js create mode 100644 packages/backend/src/apps/filter/assets/favicon.svg create mode 100644 packages/backend/src/apps/filter/index.js create mode 100644 packages/backend/src/apps/flickr/assets/favicon.svg create mode 100644 packages/backend/src/apps/flickr/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/flickr/auth/index.js create mode 100644 packages/backend/src/apps/flickr/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/flickr/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/flickr/common/add-auth-header.js create mode 100644 packages/backend/src/apps/flickr/common/oauth-client.js create mode 100644 packages/backend/src/apps/flickr/dynamic-data/index.js create mode 100644 packages/backend/src/apps/flickr/dynamic-data/list-albums/index.js create mode 100644 packages/backend/src/apps/flickr/index.js create mode 100644 packages/backend/src/apps/flickr/triggers/index.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-albums/index.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-albums/new-albums.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-favorite-photos/index.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-favorite-photos/new-favorite-photos.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-photos-in-album/index.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-photos-in-album/new-photos-in-album.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-photos/index.js create mode 100644 packages/backend/src/apps/flickr/triggers/new-photos/new-photos.js create mode 100644 packages/backend/src/apps/flowers-software/assets/favicon.svg create mode 100644 packages/backend/src/apps/flowers-software/auth/index.js create mode 100644 packages/backend/src/apps/flowers-software/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/flowers-software/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/flowers-software/common/add-auth-header.js create mode 100644 packages/backend/src/apps/flowers-software/common/get-webhooks.js create mode 100644 packages/backend/src/apps/flowers-software/common/webhook-filters.js create mode 100644 packages/backend/src/apps/flowers-software/index.js create mode 100644 packages/backend/src/apps/flowers-software/triggers/index.js create mode 100644 packages/backend/src/apps/flowers-software/triggers/new-activity/index.js create mode 100644 packages/backend/src/apps/formatter/actions/date-time/index.js create mode 100644 packages/backend/src/apps/formatter/actions/date-time/transformers/format-date-time.js create mode 100644 packages/backend/src/apps/formatter/actions/date-time/transformers/get-current-timestamp.js create mode 100644 packages/backend/src/apps/formatter/actions/index.js create mode 100644 packages/backend/src/apps/formatter/actions/numbers/index.js create mode 100644 packages/backend/src/apps/formatter/actions/numbers/transformers/format-number.js create mode 100644 packages/backend/src/apps/formatter/actions/numbers/transformers/format-phone-number.js create mode 100644 packages/backend/src/apps/formatter/actions/numbers/transformers/perform-math-operation.js create mode 100644 packages/backend/src/apps/formatter/actions/numbers/transformers/random-number.js create mode 100644 packages/backend/src/apps/formatter/actions/text/index.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/base64-to-string.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/capitalize.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/create-uuid.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/encode-uri-component.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/encode-uri.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/extract-email-address.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/extract-number.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/html-to-markdown.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/lowercase.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/markdown-to-html.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/parse-stringified-json.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/pluralize.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/replace.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/string-to-base64.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/trim-whitespace.js create mode 100644 packages/backend/src/apps/formatter/actions/text/transformers/use-default-value.js create mode 100644 packages/backend/src/apps/formatter/assets/favicon.svg create mode 100644 packages/backend/src/apps/formatter/common/phone-number-country-codes.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-replace-regex-options/index.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/format-date-time.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/format.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/timezone.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/index.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-number.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-phone-number.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/perform-math-operation.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/random-number.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/base64-to-string.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/capitalize.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri-component.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-email-address.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-number.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/html-to-markdown.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/lowercase.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/markdown-to-html.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/parse-stringified-json.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/pluralize.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/replace.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/string-to-base64.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/trim-whitespace.js create mode 100644 packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/use-default-value.js create mode 100644 packages/backend/src/apps/formatter/index.js create mode 100644 packages/backend/src/apps/freescout/assets/favicon.svg create mode 100644 packages/backend/src/apps/freescout/auth/index.js create mode 100644 packages/backend/src/apps/freescout/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/freescout/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/freescout/common/add-auth-header.js create mode 100644 packages/backend/src/apps/freescout/common/set-base-url.js create mode 100644 packages/backend/src/apps/freescout/common/webhook-filters.js create mode 100644 packages/backend/src/apps/freescout/index.js create mode 100644 packages/backend/src/apps/freescout/triggers/index.js create mode 100644 packages/backend/src/apps/freescout/triggers/new-event/index.js create mode 100644 packages/backend/src/apps/ghost/assets/favicon.svg create mode 100644 packages/backend/src/apps/ghost/auth/index.js create mode 100644 packages/backend/src/apps/ghost/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/ghost/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/ghost/common/add-auth-header.js create mode 100644 packages/backend/src/apps/ghost/common/set-base-url.js create mode 100644 packages/backend/src/apps/ghost/index.js create mode 100644 packages/backend/src/apps/ghost/triggers/index.js create mode 100644 packages/backend/src/apps/ghost/triggers/new-post-published/index.js create mode 100644 packages/backend/src/apps/github/actions/create-issue/index.js create mode 100644 packages/backend/src/apps/github/actions/index.js create mode 100644 packages/backend/src/apps/github/assets/favicon.svg create mode 100644 packages/backend/src/apps/github/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/github/auth/index.js create mode 100644 packages/backend/src/apps/github/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/github/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/github/common/add-auth-header.js create mode 100644 packages/backend/src/apps/github/common/get-current-user.js create mode 100644 packages/backend/src/apps/github/common/get-repo-owner-and-repo.js create mode 100644 packages/backend/src/apps/github/common/paginate-all.js create mode 100644 packages/backend/src/apps/github/dynamic-data/index.js create mode 100644 packages/backend/src/apps/github/dynamic-data/list-labels/index.js create mode 100644 packages/backend/src/apps/github/dynamic-data/list-repos/index.js create mode 100644 packages/backend/src/apps/github/index.js create mode 100644 packages/backend/src/apps/github/triggers/index.js create mode 100644 packages/backend/src/apps/github/triggers/new-issues/index.js create mode 100644 packages/backend/src/apps/github/triggers/new-issues/new-issues.js create mode 100644 packages/backend/src/apps/github/triggers/new-pull-requests/index.js create mode 100644 packages/backend/src/apps/github/triggers/new-pull-requests/new-pull-requests.js create mode 100644 packages/backend/src/apps/github/triggers/new-stargazers/index.js create mode 100644 packages/backend/src/apps/github/triggers/new-stargazers/new-stargazers.js create mode 100644 packages/backend/src/apps/github/triggers/new-watchers/index.js create mode 100644 packages/backend/src/apps/github/triggers/new-watchers/new-watchers.js create mode 100644 packages/backend/src/apps/gitlab/assets/favicon.svg create mode 100644 packages/backend/src/apps/gitlab/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/gitlab/auth/index.js create mode 100644 packages/backend/src/apps/gitlab/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/gitlab/auth/refresh-token.js create mode 100644 packages/backend/src/apps/gitlab/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/gitlab/common/add-auth-header.js create mode 100644 packages/backend/src/apps/gitlab/common/get-base-url.js create mode 100644 packages/backend/src/apps/gitlab/common/get-current-user.js create mode 100644 packages/backend/src/apps/gitlab/common/paginate-all.js create mode 100644 packages/backend/src/apps/gitlab/common/set-base-url.js create mode 100644 packages/backend/src/apps/gitlab/dynamic-data/index.js create mode 100644 packages/backend/src/apps/gitlab/dynamic-data/list-projects/index.js create mode 100644 packages/backend/src/apps/gitlab/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/confidential-issue-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/confidential-issue-event/issue_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/confidential-note-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/confidential-note-event/note_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/deployment-event/deployment_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/deployment-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/feature-flag-event/feature_flag_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/feature-flag-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/issue-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/issue-event/issue_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/job-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/job-event/job_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/lib.js create mode 100644 packages/backend/src/apps/gitlab/triggers/merge-request-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/merge-request-event/merge_request_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/note-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/note-event/note_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/pipeline-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/pipeline-event/pipeline_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/push-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/push-event/push_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/release-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/release-event/release_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/tag-push-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/tag-push-event/tag_push_event.js create mode 100644 packages/backend/src/apps/gitlab/triggers/types.js create mode 100644 packages/backend/src/apps/gitlab/triggers/wiki-page-event/index.js create mode 100644 packages/backend/src/apps/gitlab/triggers/wiki-page-event/wiki_page_event.js create mode 100644 packages/backend/src/apps/google-calendar/assets/favicon.svg create mode 100644 packages/backend/src/apps/google-calendar/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/google-calendar/auth/index.js create mode 100644 packages/backend/src/apps/google-calendar/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/google-calendar/auth/refresh-token.js create mode 100644 packages/backend/src/apps/google-calendar/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/google-calendar/common/add-auth-header.js create mode 100644 packages/backend/src/apps/google-calendar/common/auth-scope.js create mode 100644 packages/backend/src/apps/google-calendar/common/get-current-user.js create mode 100644 packages/backend/src/apps/google-calendar/dynamic-data/index.js create mode 100644 packages/backend/src/apps/google-calendar/dynamic-data/list-calendars/index.js create mode 100644 packages/backend/src/apps/google-calendar/index.js create mode 100644 packages/backend/src/apps/google-calendar/triggers/index.js create mode 100644 packages/backend/src/apps/google-calendar/triggers/new-calendar/index.js create mode 100644 packages/backend/src/apps/google-calendar/triggers/new-event/index.js create mode 100644 packages/backend/src/apps/google-drive/assets/favicon.svg create mode 100644 packages/backend/src/apps/google-drive/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/google-drive/auth/index.js create mode 100644 packages/backend/src/apps/google-drive/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/google-drive/auth/refresh-token.js create mode 100644 packages/backend/src/apps/google-drive/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/google-drive/common/add-auth-header.js create mode 100644 packages/backend/src/apps/google-drive/common/auth-scope.js create mode 100644 packages/backend/src/apps/google-drive/common/get-current-user.js create mode 100644 packages/backend/src/apps/google-drive/dynamic-data/index.js create mode 100644 packages/backend/src/apps/google-drive/dynamic-data/list-drives/index.js create mode 100644 packages/backend/src/apps/google-drive/dynamic-data/list-folders/index.js create mode 100644 packages/backend/src/apps/google-drive/index.js create mode 100644 packages/backend/src/apps/google-drive/triggers/index.js create mode 100644 packages/backend/src/apps/google-drive/triggers/new-files-in-folder/index.js create mode 100644 packages/backend/src/apps/google-drive/triggers/new-files-in-folder/new-files-in-folder.js create mode 100644 packages/backend/src/apps/google-drive/triggers/new-files/index.js create mode 100644 packages/backend/src/apps/google-drive/triggers/new-files/new-files.js create mode 100644 packages/backend/src/apps/google-drive/triggers/new-folders/index.js create mode 100644 packages/backend/src/apps/google-drive/triggers/new-folders/new-folders.js create mode 100644 packages/backend/src/apps/google-drive/triggers/updated-files/index.js create mode 100644 packages/backend/src/apps/google-drive/triggers/updated-files/updated-files.js create mode 100644 packages/backend/src/apps/google-forms/assets/favicon.svg create mode 100644 packages/backend/src/apps/google-forms/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/google-forms/auth/index.js create mode 100644 packages/backend/src/apps/google-forms/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/google-forms/auth/refresh-token.js create mode 100644 packages/backend/src/apps/google-forms/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/google-forms/common/add-auth-header.js create mode 100644 packages/backend/src/apps/google-forms/common/auth-scope.js create mode 100644 packages/backend/src/apps/google-forms/common/get-current-user.js create mode 100644 packages/backend/src/apps/google-forms/dynamic-data/index.js create mode 100644 packages/backend/src/apps/google-forms/dynamic-data/list-forms/index.js create mode 100644 packages/backend/src/apps/google-forms/index.js create mode 100644 packages/backend/src/apps/google-forms/triggers/index.js create mode 100644 packages/backend/src/apps/google-forms/triggers/new-form-responses/index.js create mode 100644 packages/backend/src/apps/google-forms/triggers/new-form-responses/new-form-responses.js create mode 100644 packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.js create mode 100644 packages/backend/src/apps/google-sheets/actions/create-spreadsheet/index.js create mode 100644 packages/backend/src/apps/google-sheets/actions/create-worksheet/index.js create mode 100644 packages/backend/src/apps/google-sheets/actions/find-worksheet/index.js create mode 100644 packages/backend/src/apps/google-sheets/actions/index.js create mode 100644 packages/backend/src/apps/google-sheets/assets/favicon.svg create mode 100644 packages/backend/src/apps/google-sheets/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/google-sheets/auth/index.js create mode 100644 packages/backend/src/apps/google-sheets/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/google-sheets/auth/refresh-token.js create mode 100644 packages/backend/src/apps/google-sheets/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/google-sheets/common/add-auth-header.js create mode 100644 packages/backend/src/apps/google-sheets/common/auth-scope.js create mode 100644 packages/backend/src/apps/google-sheets/common/get-current-user.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-data/index.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-data/list-drives/index.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-data/list-spreadsheets/index.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-data/list-worksheets/index.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-fields/list-create-worksheet-fields/index.js create mode 100644 packages/backend/src/apps/google-sheets/dynamic-fields/list-sheet-headers/index.js create mode 100644 packages/backend/src/apps/google-sheets/index.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/index.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/index.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/new-spreadsheet-rows.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/index.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/new-spreadsheets.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/new-worksheets/index.js create mode 100644 packages/backend/src/apps/google-sheets/triggers/new-worksheets/new-worksheets.js create mode 100644 packages/backend/src/apps/google-tasks/actions/create-task-list/index.js create mode 100644 packages/backend/src/apps/google-tasks/actions/create-task/index.js create mode 100644 packages/backend/src/apps/google-tasks/actions/find-task/index.js create mode 100644 packages/backend/src/apps/google-tasks/actions/index.js create mode 100644 packages/backend/src/apps/google-tasks/actions/update-task/index.js create mode 100644 packages/backend/src/apps/google-tasks/assets/favicon.svg create mode 100644 packages/backend/src/apps/google-tasks/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/google-tasks/auth/index.js create mode 100644 packages/backend/src/apps/google-tasks/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/google-tasks/auth/refresh-token.js create mode 100644 packages/backend/src/apps/google-tasks/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/google-tasks/common/add-auth-header.js create mode 100644 packages/backend/src/apps/google-tasks/common/auth-scope.js create mode 100644 packages/backend/src/apps/google-tasks/common/get-current-user.js create mode 100644 packages/backend/src/apps/google-tasks/dynamic-data/index.js create mode 100644 packages/backend/src/apps/google-tasks/dynamic-data/list-task-lists/index.js create mode 100644 packages/backend/src/apps/google-tasks/dynamic-data/list-tasks/index.js create mode 100644 packages/backend/src/apps/google-tasks/index.js create mode 100644 packages/backend/src/apps/google-tasks/triggers/index.js create mode 100644 packages/backend/src/apps/google-tasks/triggers/new-completed-tasks/index.js create mode 100644 packages/backend/src/apps/google-tasks/triggers/new-task-lists/index.js create mode 100644 packages/backend/src/apps/google-tasks/triggers/new-tasks/index.js create mode 100644 packages/backend/src/apps/helix/actions/index.js create mode 100644 packages/backend/src/apps/helix/actions/new-chat/index.js create mode 100644 packages/backend/src/apps/helix/assets/favicon.svg create mode 100644 packages/backend/src/apps/helix/auth/index.js create mode 100644 packages/backend/src/apps/helix/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/helix/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/helix/common/add-auth-header.js create mode 100644 packages/backend/src/apps/helix/common/set-base-url.js create mode 100644 packages/backend/src/apps/helix/index.js create mode 100644 packages/backend/src/apps/http-request/actions/custom-request/index.js create mode 100644 packages/backend/src/apps/http-request/actions/index.js create mode 100644 packages/backend/src/apps/http-request/assets/favicon.svg create mode 100644 packages/backend/src/apps/http-request/index.js create mode 100644 packages/backend/src/apps/hubspot/actions/create-contact/index.js create mode 100644 packages/backend/src/apps/hubspot/actions/index.js create mode 100644 packages/backend/src/apps/hubspot/assets/favicon.svg create mode 100644 packages/backend/src/apps/hubspot/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/hubspot/auth/index.js create mode 100644 packages/backend/src/apps/hubspot/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/hubspot/auth/refresh-token.js create mode 100644 packages/backend/src/apps/hubspot/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/hubspot/common/add-auth-header.js create mode 100644 packages/backend/src/apps/hubspot/common/get-access-token-info.js create mode 100644 packages/backend/src/apps/hubspot/common/scopes.js create mode 100644 packages/backend/src/apps/hubspot/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-client/fields.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-client/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-invoice/fields.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-invoice/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-payment/fields.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-payment/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-product/fields.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/create-product/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/actions/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/assets/favicon.svg create mode 100644 packages/backend/src/apps/invoice-ninja/auth/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/invoice-ninja/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/invoice-ninja/common/add-auth-header.js create mode 100644 packages/backend/src/apps/invoice-ninja/common/filter-provided-fields.js create mode 100644 packages/backend/src/apps/invoice-ninja/common/set-base-url.js create mode 100644 packages/backend/src/apps/invoice-ninja/dynamic-data/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/dynamic-data/list-clients/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/dynamic-data/list-invoices/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/new-clients/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/new-credits/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/new-invoices/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/new-payments/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/new-projects/index.js create mode 100644 packages/backend/src/apps/invoice-ninja/triggers/new-quotes/index.js create mode 100644 packages/backend/src/apps/jotform/assets/favicon.svg create mode 100644 packages/backend/src/apps/jotform/auth/index.js create mode 100644 packages/backend/src/apps/jotform/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/jotform/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/jotform/common/add-auth-header.js create mode 100644 packages/backend/src/apps/jotform/common/get-current-user.js create mode 100644 packages/backend/src/apps/jotform/common/set-base-url.js create mode 100644 packages/backend/src/apps/jotform/dynamic-data/index.js create mode 100644 packages/backend/src/apps/jotform/dynamic-data/list-forms/index.js create mode 100644 packages/backend/src/apps/jotform/index.js create mode 100644 packages/backend/src/apps/jotform/triggers/index.js create mode 100644 packages/backend/src/apps/jotform/triggers/new-submissions/index.js create mode 100644 packages/backend/src/apps/mailchimp/actions/create-campaign/index.js create mode 100644 packages/backend/src/apps/mailchimp/actions/index.js create mode 100644 packages/backend/src/apps/mailchimp/actions/send-campaign/index.js create mode 100644 packages/backend/src/apps/mailchimp/assets/favicon.svg create mode 100644 packages/backend/src/apps/mailchimp/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/mailchimp/auth/index.js create mode 100644 packages/backend/src/apps/mailchimp/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/mailchimp/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/mailchimp/common/add-auth-header.js create mode 100644 packages/backend/src/apps/mailchimp/common/get-current-user.js create mode 100644 packages/backend/src/apps/mailchimp/common/set-base-url.js create mode 100644 packages/backend/src/apps/mailchimp/dynamic-data/index.js create mode 100644 packages/backend/src/apps/mailchimp/dynamic-data/list-audiences/index.js create mode 100644 packages/backend/src/apps/mailchimp/dynamic-data/list-campaigns/index.js create mode 100644 packages/backend/src/apps/mailchimp/dynamic-data/list-segments-or-tags/index.js create mode 100644 packages/backend/src/apps/mailchimp/dynamic-data/list-templates/index.js create mode 100644 packages/backend/src/apps/mailchimp/index.js create mode 100644 packages/backend/src/apps/mailchimp/triggers/email-opened/index.js create mode 100644 packages/backend/src/apps/mailchimp/triggers/index.js create mode 100644 packages/backend/src/apps/mailchimp/triggers/new-subscribers/index.js create mode 100644 packages/backend/src/apps/mailchimp/triggers/new-unsubscribers/index.js create mode 100644 packages/backend/src/apps/mailerlite/assets/favicon.svg create mode 100644 packages/backend/src/apps/mailerlite/auth/index.js create mode 100644 packages/backend/src/apps/mailerlite/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/mailerlite/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/mailerlite/common/add-auth-header.js create mode 100644 packages/backend/src/apps/mailerlite/index.js create mode 100644 packages/backend/src/apps/mailerlite/triggers/campaign-sent/index.js create mode 100644 packages/backend/src/apps/mailerlite/triggers/index.js create mode 100644 packages/backend/src/apps/mailerlite/triggers/spam-complaint/index.js create mode 100644 packages/backend/src/apps/mailerlite/triggers/subscriber-created/index.js create mode 100644 packages/backend/src/apps/mailerlite/triggers/subscriber-unsubscribed/index.js create mode 100644 packages/backend/src/apps/mattermost/actions/index.js create mode 100644 packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.js create mode 100644 packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.js create mode 100644 packages/backend/src/apps/mattermost/assets/favicon.svg create mode 100644 packages/backend/src/apps/mattermost/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/mattermost/auth/index.js create mode 100644 packages/backend/src/apps/mattermost/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/mattermost/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/mattermost/common/add-auth-header.js create mode 100644 packages/backend/src/apps/mattermost/common/add-x-requested-with-header.js create mode 100644 packages/backend/src/apps/mattermost/common/get-base-url.js create mode 100644 packages/backend/src/apps/mattermost/common/get-current-user.js create mode 100644 packages/backend/src/apps/mattermost/common/set-base-url.js create mode 100644 packages/backend/src/apps/mattermost/dynamic-data/index.js create mode 100644 packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.js create mode 100644 packages/backend/src/apps/mattermost/index.js create mode 100644 packages/backend/src/apps/miro/actions/copy-board/index.js create mode 100644 packages/backend/src/apps/miro/actions/create-board/index.js create mode 100644 packages/backend/src/apps/miro/actions/create-card-widget/index.js create mode 100644 packages/backend/src/apps/miro/actions/index.js create mode 100644 packages/backend/src/apps/miro/assets/favicon.svg create mode 100644 packages/backend/src/apps/miro/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/miro/auth/index.js create mode 100644 packages/backend/src/apps/miro/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/miro/auth/refresh-token.js create mode 100644 packages/backend/src/apps/miro/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/miro/common/add-auth-header.js create mode 100644 packages/backend/src/apps/miro/common/get-current-user.js create mode 100644 packages/backend/src/apps/miro/dynamic-data/index.js create mode 100644 packages/backend/src/apps/miro/dynamic-data/list-boards/index.js create mode 100644 packages/backend/src/apps/miro/dynamic-data/list-frames/index.js create mode 100644 packages/backend/src/apps/miro/index.js create mode 100644 packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js create mode 100644 packages/backend/src/apps/mistral-ai/actions/index.js create mode 100644 packages/backend/src/apps/mistral-ai/assets/favicon.svg create mode 100644 packages/backend/src/apps/mistral-ai/auth/index.js create mode 100644 packages/backend/src/apps/mistral-ai/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/mistral-ai/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/mistral-ai/common/add-auth-header.js create mode 100644 packages/backend/src/apps/mistral-ai/dynamic-data/index.js create mode 100644 packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/mistral-ai/index.js create mode 100644 packages/backend/src/apps/notion/actions/create-database-item/index.js create mode 100644 packages/backend/src/apps/notion/actions/create-page/index.js create mode 100644 packages/backend/src/apps/notion/actions/find-database-item/index.js create mode 100644 packages/backend/src/apps/notion/actions/index.js create mode 100644 packages/backend/src/apps/notion/assets/favicon.svg create mode 100644 packages/backend/src/apps/notion/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/notion/auth/index.js create mode 100644 packages/backend/src/apps/notion/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/notion/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/notion/common/add-auth-header.js create mode 100644 packages/backend/src/apps/notion/common/add-notion-version-header.js create mode 100644 packages/backend/src/apps/notion/common/get-current-user.js create mode 100644 packages/backend/src/apps/notion/dynamic-data/index.js create mode 100644 packages/backend/src/apps/notion/dynamic-data/list-databases/index.js create mode 100644 packages/backend/src/apps/notion/dynamic-data/list-parent-pages/index.js create mode 100644 packages/backend/src/apps/notion/index.js create mode 100644 packages/backend/src/apps/notion/triggers/index.js create mode 100644 packages/backend/src/apps/notion/triggers/new-database-items/index.js create mode 100644 packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.js create mode 100644 packages/backend/src/apps/notion/triggers/updated-database-items/index.js create mode 100644 packages/backend/src/apps/notion/triggers/updated-database-items/updated-database-items.js create mode 100644 packages/backend/src/apps/ntfy/actions/index.js create mode 100644 packages/backend/src/apps/ntfy/actions/send-message/index.js create mode 100644 packages/backend/src/apps/ntfy/assets/favicon.svg create mode 100644 packages/backend/src/apps/ntfy/auth/index.js create mode 100644 packages/backend/src/apps/ntfy/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/ntfy/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/ntfy/common/add-auth-header.js create mode 100644 packages/backend/src/apps/ntfy/index.js create mode 100644 packages/backend/src/apps/odoo/actions/create-lead/index.js create mode 100644 packages/backend/src/apps/odoo/actions/index.js create mode 100644 packages/backend/src/apps/odoo/assets/favicon.svg create mode 100644 packages/backend/src/apps/odoo/auth/index.js create mode 100644 packages/backend/src/apps/odoo/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/odoo/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/odoo/common/xmlrpc-client.js create mode 100644 packages/backend/src/apps/odoo/index.js create mode 100644 packages/backend/src/apps/openai/actions/check-moderation/index.js create mode 100644 packages/backend/src/apps/openai/actions/index.js create mode 100644 packages/backend/src/apps/openai/actions/send-chat-prompt/index.js create mode 100644 packages/backend/src/apps/openai/actions/send-prompt/index.js create mode 100644 packages/backend/src/apps/openai/assets/favicon.svg create mode 100644 packages/backend/src/apps/openai/auth/index.js create mode 100644 packages/backend/src/apps/openai/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/openai/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/openai/common/add-auth-header.js create mode 100644 packages/backend/src/apps/openai/dynamic-data/index.js create mode 100644 packages/backend/src/apps/openai/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/openai/index.js create mode 100644 packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js create mode 100644 packages/backend/src/apps/openrouter/actions/index.js create mode 100644 packages/backend/src/apps/openrouter/assets/favicon.svg create mode 100644 packages/backend/src/apps/openrouter/auth/index.js create mode 100644 packages/backend/src/apps/openrouter/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/openrouter/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/openrouter/common/add-auth-header.js create mode 100644 packages/backend/src/apps/openrouter/dynamic-data/index.js create mode 100644 packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/openrouter/index.js create mode 100644 packages/backend/src/apps/perplexity/actions/index.js create mode 100644 packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js create mode 100644 packages/backend/src/apps/perplexity/assets/favicon.svg create mode 100644 packages/backend/src/apps/perplexity/auth/index.js create mode 100644 packages/backend/src/apps/perplexity/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/perplexity/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/perplexity/common/add-auth-header.js create mode 100644 packages/backend/src/apps/perplexity/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/create-activity/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/create-deal/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/create-lead/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/create-note/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/create-organization/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/create-person/index.js create mode 100644 packages/backend/src/apps/pipedrive/actions/index.js create mode 100644 packages/backend/src/apps/pipedrive/assets/favicon.svg create mode 100644 packages/backend/src/apps/pipedrive/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/pipedrive/auth/index.js create mode 100644 packages/backend/src/apps/pipedrive/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/pipedrive/auth/refresh-token.js create mode 100644 packages/backend/src/apps/pipedrive/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/pipedrive/common/add-auth-header.js create mode 100644 packages/backend/src/apps/pipedrive/common/filter-provided-fields.js create mode 100644 packages/backend/src/apps/pipedrive/common/get-current-user.js create mode 100644 packages/backend/src/apps/pipedrive/common/set-base-url.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-activity-types/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-currencies/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-deals/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-lead-labels/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-leads/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-organization-label-field/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-organizations/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-person-label-field/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-persons/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-stages/index.js create mode 100644 packages/backend/src/apps/pipedrive/dynamic-data/list-users/index.js create mode 100644 packages/backend/src/apps/pipedrive/index.js create mode 100644 packages/backend/src/apps/pipedrive/triggers/index.js create mode 100644 packages/backend/src/apps/pipedrive/triggers/new-activities/index.js create mode 100644 packages/backend/src/apps/pipedrive/triggers/new-deals/index.js create mode 100644 packages/backend/src/apps/pipedrive/triggers/new-leads/index.js create mode 100644 packages/backend/src/apps/pipedrive/triggers/new-notes/index.js create mode 100644 packages/backend/src/apps/placetel/assets/favicon.svg create mode 100644 packages/backend/src/apps/placetel/auth/index.js create mode 100644 packages/backend/src/apps/placetel/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/placetel/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/placetel/common/add-auth-header.js create mode 100644 packages/backend/src/apps/placetel/dynamic-data/index.js create mode 100644 packages/backend/src/apps/placetel/dynamic-data/list-numbers/index.js create mode 100644 packages/backend/src/apps/placetel/index.js create mode 100644 packages/backend/src/apps/placetel/triggers/hungup-call/index.js create mode 100644 packages/backend/src/apps/placetel/triggers/index.js create mode 100644 packages/backend/src/apps/postgresql/actions/delete/index.js create mode 100644 packages/backend/src/apps/postgresql/actions/index.js create mode 100644 packages/backend/src/apps/postgresql/actions/insert/index.js create mode 100644 packages/backend/src/apps/postgresql/actions/sql-query/index.js create mode 100644 packages/backend/src/apps/postgresql/actions/update/index.js create mode 100644 packages/backend/src/apps/postgresql/assets/favicon.svg create mode 100644 packages/backend/src/apps/postgresql/auth/index.js create mode 100644 packages/backend/src/apps/postgresql/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/postgresql/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/postgresql/common/postgres-client.js create mode 100644 packages/backend/src/apps/postgresql/common/set-run-time-parameters.js create mode 100644 packages/backend/src/apps/postgresql/common/where-clause-operators.js create mode 100644 packages/backend/src/apps/postgresql/index.js create mode 100644 packages/backend/src/apps/pushover/actions/index.js create mode 100644 packages/backend/src/apps/pushover/actions/send-a-pushover-notification/index.js create mode 100644 packages/backend/src/apps/pushover/assets/favicon.svg create mode 100644 packages/backend/src/apps/pushover/auth/index.js create mode 100644 packages/backend/src/apps/pushover/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/pushover/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/pushover/dynamic-data/index.js create mode 100644 packages/backend/src/apps/pushover/dynamic-data/list-devices/index.js create mode 100644 packages/backend/src/apps/pushover/dynamic-data/list-sounds/index.js create mode 100644 packages/backend/src/apps/pushover/index.js create mode 100644 packages/backend/src/apps/reddit/actions/create-link-post/index.js create mode 100644 packages/backend/src/apps/reddit/actions/index.js create mode 100644 packages/backend/src/apps/reddit/assets/favicon.svg create mode 100644 packages/backend/src/apps/reddit/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/reddit/auth/index.js create mode 100644 packages/backend/src/apps/reddit/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/reddit/auth/refresh-token.js create mode 100644 packages/backend/src/apps/reddit/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/reddit/common/add-auth-header.js create mode 100644 packages/backend/src/apps/reddit/common/auth-scope.js create mode 100644 packages/backend/src/apps/reddit/common/get-current-user.js create mode 100644 packages/backend/src/apps/reddit/index.js create mode 100644 packages/backend/src/apps/reddit/triggers/index.js create mode 100644 packages/backend/src/apps/reddit/triggers/new-posts-matching-search/index.js create mode 100644 packages/backend/src/apps/removebg/actions/index.js create mode 100644 packages/backend/src/apps/removebg/actions/remove-image-background/index.js create mode 100644 packages/backend/src/apps/removebg/assets/favicon.svg create mode 100644 packages/backend/src/apps/removebg/auth/index.js create mode 100644 packages/backend/src/apps/removebg/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/removebg/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/removebg/common/add-auth-header.js create mode 100644 packages/backend/src/apps/removebg/index.js create mode 100644 packages/backend/src/apps/rss/assets/favicon.svg create mode 100644 packages/backend/src/apps/rss/index.js create mode 100644 packages/backend/src/apps/rss/triggers/index.js create mode 100644 packages/backend/src/apps/rss/triggers/new-items-in-feed/index.js create mode 100644 packages/backend/src/apps/rss/triggers/new-items-in-feed/new-items-in-feed.js create mode 100644 packages/backend/src/apps/salesforce/actions/create-attachment/index.js create mode 100644 packages/backend/src/apps/salesforce/actions/execute-query/index.js create mode 100644 packages/backend/src/apps/salesforce/actions/find-partially-matching-record/index.js create mode 100644 packages/backend/src/apps/salesforce/actions/find-record/index.js create mode 100644 packages/backend/src/apps/salesforce/actions/index.js create mode 100644 packages/backend/src/apps/salesforce/assets/favicon.svg create mode 100644 packages/backend/src/apps/salesforce/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/salesforce/auth/index.js create mode 100644 packages/backend/src/apps/salesforce/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/salesforce/auth/refresh-token.js create mode 100644 packages/backend/src/apps/salesforce/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/salesforce/common/add-auth-header.js create mode 100644 packages/backend/src/apps/salesforce/common/get-current-user.js create mode 100644 packages/backend/src/apps/salesforce/dynamic-data/index.js create mode 100644 packages/backend/src/apps/salesforce/dynamic-data/list-fields/index.js create mode 100644 packages/backend/src/apps/salesforce/dynamic-data/list-objects/index.js create mode 100644 packages/backend/src/apps/salesforce/index.js create mode 100644 packages/backend/src/apps/salesforce/triggers/index.js create mode 100644 packages/backend/src/apps/salesforce/triggers/updated-field-in-records/index.js create mode 100644 packages/backend/src/apps/salesforce/triggers/updated-field-in-records/updated-field-in-records.js create mode 100644 packages/backend/src/apps/scheduler/assets/favicon.svg create mode 100644 packages/backend/src/apps/scheduler/common/cron-times.js create mode 100644 packages/backend/src/apps/scheduler/common/get-date-time-object.js create mode 100644 packages/backend/src/apps/scheduler/common/get-next-cron-date-time.js create mode 100644 packages/backend/src/apps/scheduler/index.js create mode 100644 packages/backend/src/apps/scheduler/triggers/every-day/index.js create mode 100644 packages/backend/src/apps/scheduler/triggers/every-hour/index.js create mode 100644 packages/backend/src/apps/scheduler/triggers/every-month/index.js create mode 100644 packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js create mode 100644 packages/backend/src/apps/scheduler/triggers/every-week/index.js create mode 100644 packages/backend/src/apps/scheduler/triggers/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/actions/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/actions/send-chat-prompt/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/actions/send-prompt/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/assets/favicon.svg create mode 100644 packages/backend/src/apps/self-hosted-llm/auth/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/self-hosted-llm/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/self-hosted-llm/common/add-auth-header.js create mode 100644 packages/backend/src/apps/self-hosted-llm/common/set-base-url.js create mode 100644 packages/backend/src/apps/self-hosted-llm/dynamic-data/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/self-hosted-llm/index.js create mode 100644 packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js create mode 100644 packages/backend/src/apps/signalwire/actions/index.js create mode 100644 packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js create mode 100644 packages/backend/src/apps/signalwire/actions/send-sms/index.js create mode 100644 packages/backend/src/apps/signalwire/assets/favicon.svg create mode 100644 packages/backend/src/apps/signalwire/auth/index.js create mode 100644 packages/backend/src/apps/signalwire/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/signalwire/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/signalwire/common/add-auth-header.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js create mode 100644 packages/backend/src/apps/signalwire/index.js create mode 100644 packages/backend/src/apps/signalwire/triggers/index.js create mode 100644 packages/backend/src/apps/signalwire/triggers/receive-call/index.js create mode 100644 packages/backend/src/apps/signalwire/triggers/receive-sms/fetch-messages.js create mode 100644 packages/backend/src/apps/signalwire/triggers/receive-sms/index.js create mode 100644 packages/backend/src/apps/slack/actions/find-message/find-message.js create mode 100644 packages/backend/src/apps/slack/actions/find-message/index.js create mode 100644 packages/backend/src/apps/slack/actions/find-user-by-email/index.js create mode 100644 packages/backend/src/apps/slack/actions/index.js create mode 100644 packages/backend/src/apps/slack/actions/send-a-direct-message/index.js create mode 100644 packages/backend/src/apps/slack/actions/send-a-direct-message/post-message.js create mode 100644 packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.js create mode 100644 packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.js create mode 100644 packages/backend/src/apps/slack/assets/favicon.svg create mode 100644 packages/backend/src/apps/slack/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/slack/auth/index.js create mode 100644 packages/backend/src/apps/slack/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/slack/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/slack/common/add-auth-header.js create mode 100644 packages/backend/src/apps/slack/common/get-current-user.js create mode 100644 packages/backend/src/apps/slack/dynamic-data/index.js create mode 100644 packages/backend/src/apps/slack/dynamic-data/list-channels/index.js create mode 100644 packages/backend/src/apps/slack/dynamic-data/list-users/index.js create mode 100644 packages/backend/src/apps/slack/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.js create mode 100644 packages/backend/src/apps/slack/index.js create mode 100644 packages/backend/src/apps/smtp/actions/index.js create mode 100644 packages/backend/src/apps/smtp/actions/send-email/index.js create mode 100644 packages/backend/src/apps/smtp/assets/favicon.svg create mode 100644 packages/backend/src/apps/smtp/auth/index.js create mode 100644 packages/backend/src/apps/smtp/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/smtp/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/smtp/common/transporter.js create mode 100644 packages/backend/src/apps/smtp/index.js create mode 100644 packages/backend/src/apps/spotify/actions/create-playlist/index.js create mode 100644 packages/backend/src/apps/spotify/actions/index.js create mode 100644 packages/backend/src/apps/spotify/assets/favicon.svg create mode 100644 packages/backend/src/apps/spotify/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/spotify/auth/index.js create mode 100644 packages/backend/src/apps/spotify/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/spotify/auth/refresh-token.js create mode 100644 packages/backend/src/apps/spotify/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/spotify/common/add-auth-header.js create mode 100644 packages/backend/src/apps/spotify/common/get-current-user.js create mode 100644 packages/backend/src/apps/spotify/common/scopes.js create mode 100644 packages/backend/src/apps/spotify/index.js create mode 100644 packages/backend/src/apps/strava/actions/create-totals-and-stats-report/index.js create mode 100644 packages/backend/src/apps/strava/actions/index.js create mode 100644 packages/backend/src/apps/strava/assets/favicon.svg create mode 100644 packages/backend/src/apps/strava/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/strava/auth/index.js create mode 100644 packages/backend/src/apps/strava/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/strava/auth/refresh-token.js create mode 100644 packages/backend/src/apps/strava/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/strava/common/add-auth-header.js create mode 100644 packages/backend/src/apps/strava/common/get-current-user.js create mode 100644 packages/backend/src/apps/strava/index.js create mode 100644 packages/backend/src/apps/stripe/assets/favicon.svg create mode 100644 packages/backend/src/apps/stripe/auth/index.js create mode 100644 packages/backend/src/apps/stripe/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/stripe/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/stripe/common/add-auth-header.js create mode 100644 packages/backend/src/apps/stripe/index.js create mode 100644 packages/backend/src/apps/stripe/triggers/balance-transaction/get-balance-transactions.js create mode 100644 packages/backend/src/apps/stripe/triggers/balance-transaction/index.js create mode 100644 packages/backend/src/apps/stripe/triggers/index.js create mode 100644 packages/backend/src/apps/stripe/triggers/payouts/get-payouts.js create mode 100644 packages/backend/src/apps/stripe/triggers/payouts/index.js create mode 100644 packages/backend/src/apps/telegram-bot/actions/index.js create mode 100644 packages/backend/src/apps/telegram-bot/actions/send-message/index.js create mode 100644 packages/backend/src/apps/telegram-bot/assets/favicon.svg create mode 100644 packages/backend/src/apps/telegram-bot/auth/index.js create mode 100644 packages/backend/src/apps/telegram-bot/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/telegram-bot/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/telegram-bot/common/add-auth-header.js create mode 100644 packages/backend/src/apps/telegram-bot/index.js create mode 100644 packages/backend/src/apps/todoist/actions/create-task/index.js create mode 100644 packages/backend/src/apps/todoist/actions/index.js create mode 100644 packages/backend/src/apps/todoist/assets/favicon.svg create mode 100644 packages/backend/src/apps/todoist/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/todoist/auth/index.js create mode 100644 packages/backend/src/apps/todoist/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/todoist/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/todoist/common/add-auth-header.js create mode 100644 packages/backend/src/apps/todoist/dynamic-data/index.js create mode 100644 packages/backend/src/apps/todoist/dynamic-data/list-labels/index.js create mode 100644 packages/backend/src/apps/todoist/dynamic-data/list-projects/index.js create mode 100644 packages/backend/src/apps/todoist/dynamic-data/list-sections/index.js create mode 100644 packages/backend/src/apps/todoist/index.js create mode 100644 packages/backend/src/apps/todoist/triggers/get-tasks/get-tasks.js create mode 100644 packages/backend/src/apps/todoist/triggers/get-tasks/index.js create mode 100644 packages/backend/src/apps/todoist/triggers/index.js create mode 100644 packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js create mode 100644 packages/backend/src/apps/together-ai/actions/create-completion/index.js create mode 100644 packages/backend/src/apps/together-ai/actions/index.js create mode 100644 packages/backend/src/apps/together-ai/assets/favicon.svg create mode 100644 packages/backend/src/apps/together-ai/auth/index.js create mode 100644 packages/backend/src/apps/together-ai/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/together-ai/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/together-ai/common/add-auth-header.js create mode 100644 packages/backend/src/apps/together-ai/dynamic-data/index.js create mode 100644 packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js create mode 100644 packages/backend/src/apps/together-ai/index.js create mode 100644 packages/backend/src/apps/trello/actions/create-card/index.js create mode 100644 packages/backend/src/apps/trello/actions/index.js create mode 100644 packages/backend/src/apps/trello/assets/favicon.svg create mode 100644 packages/backend/src/apps/trello/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/trello/auth/index.js create mode 100644 packages/backend/src/apps/trello/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/trello/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/trello/common/add-auth-header.js create mode 100644 packages/backend/src/apps/trello/common/auth-scope.js create mode 100644 packages/backend/src/apps/trello/common/get-current-user.js create mode 100644 packages/backend/src/apps/trello/dynamic-data/index.js create mode 100644 packages/backend/src/apps/trello/dynamic-data/list-board-labels/index.js create mode 100644 packages/backend/src/apps/trello/dynamic-data/list-board-lists/index.js create mode 100644 packages/backend/src/apps/trello/dynamic-data/list-boards/index.js create mode 100644 packages/backend/src/apps/trello/dynamic-data/listMembers/index.js create mode 100644 packages/backend/src/apps/trello/index.js create mode 100644 packages/backend/src/apps/twilio/actions/index.js create mode 100644 packages/backend/src/apps/twilio/actions/send-sms/index.js create mode 100644 packages/backend/src/apps/twilio/assets/favicon.svg create mode 100644 packages/backend/src/apps/twilio/auth/index.js create mode 100644 packages/backend/src/apps/twilio/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/twilio/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/twilio/common/add-auth-header.js create mode 100644 packages/backend/src/apps/twilio/common/get-incoming-phone-number.js create mode 100644 packages/backend/src/apps/twilio/dynamic-data/index.js create mode 100644 packages/backend/src/apps/twilio/dynamic-data/list-incoming-phone-numbers/index.js create mode 100644 packages/backend/src/apps/twilio/index.js create mode 100644 packages/backend/src/apps/twilio/triggers/index.js create mode 100644 packages/backend/src/apps/twilio/triggers/receive-sms/fetch-messages.js create mode 100644 packages/backend/src/apps/twilio/triggers/receive-sms/index.js create mode 100644 packages/backend/src/apps/twitter/actions/create-tweet/index.js create mode 100644 packages/backend/src/apps/twitter/actions/index.js create mode 100644 packages/backend/src/apps/twitter/actions/search-user/index.js create mode 100644 packages/backend/src/apps/twitter/assets/favicon.svg create mode 100644 packages/backend/src/apps/twitter/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/twitter/auth/index.js create mode 100644 packages/backend/src/apps/twitter/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/twitter/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/twitter/common/add-auth-header.js create mode 100644 packages/backend/src/apps/twitter/common/get-current-user.js create mode 100644 packages/backend/src/apps/twitter/common/get-user-by-username.js create mode 100644 packages/backend/src/apps/twitter/common/get-user-followers.js create mode 100644 packages/backend/src/apps/twitter/common/get-user-tweets.js create mode 100644 packages/backend/src/apps/twitter/common/oauth-client.js create mode 100644 packages/backend/src/apps/twitter/index.js create mode 100644 packages/backend/src/apps/twitter/triggers/index.js create mode 100644 packages/backend/src/apps/twitter/triggers/my-tweets/index.js create mode 100644 packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.js create mode 100644 packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.js create mode 100644 packages/backend/src/apps/twitter/triggers/search-tweets/index.js create mode 100644 packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.js create mode 100644 packages/backend/src/apps/twitter/triggers/user-tweets/index.js create mode 100644 packages/backend/src/apps/typeform/assets/favicon.svg create mode 100644 packages/backend/src/apps/typeform/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/typeform/auth/index.js create mode 100644 packages/backend/src/apps/typeform/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/typeform/auth/refresh-token.js create mode 100644 packages/backend/src/apps/typeform/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/typeform/auth/verify-webhook.js create mode 100644 packages/backend/src/apps/typeform/common/add-auth-header.js create mode 100644 packages/backend/src/apps/typeform/common/auth-scope.js create mode 100644 packages/backend/src/apps/typeform/dynamic-data/index.js create mode 100644 packages/backend/src/apps/typeform/dynamic-data/list-forms/index.js create mode 100644 packages/backend/src/apps/typeform/index.js create mode 100644 packages/backend/src/apps/typeform/triggers/index.js create mode 100644 packages/backend/src/apps/typeform/triggers/new-entry/index.js create mode 100644 packages/backend/src/apps/virtualq/actions/create-waiter/index.js create mode 100644 packages/backend/src/apps/virtualq/actions/delete-waiter/index.js create mode 100644 packages/backend/src/apps/virtualq/actions/index.js create mode 100644 packages/backend/src/apps/virtualq/actions/show-waiter/index.js create mode 100644 packages/backend/src/apps/virtualq/actions/update-waiter/index.js create mode 100644 packages/backend/src/apps/virtualq/assets/favicon.svg create mode 100644 packages/backend/src/apps/virtualq/auth/index.js create mode 100644 packages/backend/src/apps/virtualq/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/virtualq/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/virtualq/common/add-auth-header.js create mode 100644 packages/backend/src/apps/virtualq/dynamic-data/index.js create mode 100644 packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js create mode 100644 packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js create mode 100644 packages/backend/src/apps/virtualq/dynamic-fields/index.js create mode 100644 packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js create mode 100644 packages/backend/src/apps/virtualq/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-case/fields.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-case/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-contact/fields.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-contact/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-lead/fields.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-lead/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-opportunity/fields.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-opportunity/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-todo/fields.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/create-todo/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/actions/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/assets/favicon.svg create mode 100644 packages/backend/src/apps/vtiger-crm/auth/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/vtiger-crm/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/vtiger-crm/common/add-auth-header.js create mode 100644 packages/backend/src/apps/vtiger-crm/common/set-base-url.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-assets/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-campaign-sources/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-case-options/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-contact-options/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-contacts/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-groups/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-lead-options/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-milestones/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-opportunity-options/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-organizations/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-products/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-projects/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-record-currencies/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-service-contracts/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-services/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-sla-names/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-tasks/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/dynamic-data/list-todo-options/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/new-cases/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/new-contacts/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/new-invoices/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/new-leads/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/new-opportunities/index.js create mode 100644 packages/backend/src/apps/vtiger-crm/triggers/new-todos/index.js create mode 100644 packages/backend/src/apps/webhook/actions/index.js create mode 100644 packages/backend/src/apps/webhook/actions/respond-with/index.js create mode 100644 packages/backend/src/apps/webhook/assets/favicon.svg create mode 100644 packages/backend/src/apps/webhook/index.js create mode 100644 packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.js create mode 100644 packages/backend/src/apps/webhook/triggers/index.js create mode 100644 packages/backend/src/apps/wordpress/assets/favicon.svg create mode 100644 packages/backend/src/apps/wordpress/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/wordpress/auth/index.js create mode 100644 packages/backend/src/apps/wordpress/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/wordpress/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/wordpress/common/add-auth-header.js create mode 100644 packages/backend/src/apps/wordpress/common/get-instance-url.js create mode 100644 packages/backend/src/apps/wordpress/common/set-base-url.js create mode 100644 packages/backend/src/apps/wordpress/dynamic-data/index.js create mode 100644 packages/backend/src/apps/wordpress/dynamic-data/list-statuses/index.js create mode 100644 packages/backend/src/apps/wordpress/index.js create mode 100644 packages/backend/src/apps/wordpress/triggers/index.js create mode 100644 packages/backend/src/apps/wordpress/triggers/new-comment/index.js create mode 100644 packages/backend/src/apps/wordpress/triggers/new-page/index.js create mode 100644 packages/backend/src/apps/wordpress/triggers/new-post/index.js create mode 100644 packages/backend/src/apps/xero/assets/favicon.svg create mode 100644 packages/backend/src/apps/xero/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/xero/auth/index.js create mode 100644 packages/backend/src/apps/xero/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/xero/auth/refresh-token.js create mode 100644 packages/backend/src/apps/xero/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/xero/common/add-auth-header.js create mode 100644 packages/backend/src/apps/xero/common/auth-scope.js create mode 100644 packages/backend/src/apps/xero/common/get-current-user.js create mode 100644 packages/backend/src/apps/xero/dynamic-data/index.js create mode 100644 packages/backend/src/apps/xero/dynamic-data/list-organizations/index.js create mode 100644 packages/backend/src/apps/xero/index.js create mode 100644 packages/backend/src/apps/xero/triggers/index.js create mode 100644 packages/backend/src/apps/xero/triggers/new-bank-transactions/index.js create mode 100644 packages/backend/src/apps/xero/triggers/new-payments/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/assets/favicon.svg create mode 100644 packages/backend/src/apps/you-need-a-budget/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/you-need-a-budget/auth/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/you-need-a-budget/auth/refresh-token.js create mode 100644 packages/backend/src/apps/you-need-a-budget/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/you-need-a-budget/common/add-auth-header.js create mode 100644 packages/backend/src/apps/you-need-a-budget/common/get-current-user.js create mode 100644 packages/backend/src/apps/you-need-a-budget/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/triggers/category-overspent/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/triggers/goal-completed/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/triggers/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/triggers/low-account-balance/index.js create mode 100644 packages/backend/src/apps/you-need-a-budget/triggers/new-transactions/index.js create mode 100644 packages/backend/src/apps/youtube/assets/favicon.svg create mode 100644 packages/backend/src/apps/youtube/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/youtube/auth/index.js create mode 100644 packages/backend/src/apps/youtube/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/youtube/auth/refresh-token.js create mode 100644 packages/backend/src/apps/youtube/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/youtube/common/add-auth-header.js create mode 100644 packages/backend/src/apps/youtube/common/auth-scope.js create mode 100644 packages/backend/src/apps/youtube/common/get-current-user.js create mode 100644 packages/backend/src/apps/youtube/index.js create mode 100644 packages/backend/src/apps/youtube/triggers/index.js create mode 100644 packages/backend/src/apps/youtube/triggers/new-video-by-search/index.js create mode 100644 packages/backend/src/apps/youtube/triggers/new-video-in-channel/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/create-ticket/fields.js create mode 100644 packages/backend/src/apps/zendesk/actions/create-ticket/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/create-user/fields.js create mode 100644 packages/backend/src/apps/zendesk/actions/create-user/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/delete-ticket/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/delete-user/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/find-ticket/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/index.js create mode 100644 packages/backend/src/apps/zendesk/actions/update-ticket/fields.js create mode 100644 packages/backend/src/apps/zendesk/actions/update-ticket/index.js create mode 100644 packages/backend/src/apps/zendesk/assets/favicon.svg create mode 100644 packages/backend/src/apps/zendesk/auth/generate-auth-url.js create mode 100644 packages/backend/src/apps/zendesk/auth/index.js create mode 100644 packages/backend/src/apps/zendesk/auth/is-still-verified.js create mode 100644 packages/backend/src/apps/zendesk/auth/verify-credentials.js create mode 100644 packages/backend/src/apps/zendesk/common/add-auth-headers.js create mode 100644 packages/backend/src/apps/zendesk/common/auth-scope.js create mode 100644 packages/backend/src/apps/zendesk/common/get-current-user.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-brands/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-first-page-of-tickets/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-groups/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-organizations/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-sharing-agreements/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-ticket-forms/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-users/index.js create mode 100644 packages/backend/src/apps/zendesk/dynamic-data/list-views/index.js create mode 100644 packages/backend/src/apps/zendesk/index.js create mode 100644 packages/backend/src/apps/zendesk/triggers/index.js create mode 100644 packages/backend/src/apps/zendesk/triggers/new-tickets/index.js create mode 100644 packages/backend/src/apps/zendesk/triggers/new-users/index.js create mode 100644 packages/backend/src/config/app.js create mode 100644 packages/backend/src/config/cors-options.js create mode 100644 packages/backend/src/config/database.js create mode 100644 packages/backend/src/config/orm.js create mode 100644 packages/backend/src/config/redis.js create mode 100644 packages/backend/src/controllers/api/v1/access-tokens/create-access-token.js create mode 100644 packages/backend/src/controllers/api/v1/access-tokens/create-access-token.test.js create mode 100644 packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.js create mode 100644 packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/config/update.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/create-user.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/create-user.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/delete-user.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/get-user.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/get-user.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/get-users.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/get-users.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/update-user.ee.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/update-user.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/create-connection.js create mode 100644 packages/backend/src/controllers/api/v1/apps/create-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-action-substeps.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-actions.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-actions.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-app.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-app.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-apps.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-apps.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-auth.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-auth.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-config.ee.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-connections.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-connections.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-flows.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-flows.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-triggers.js create mode 100644 packages/backend/src/controllers/api/v1/apps/get-triggers.test.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/config.ee.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/info.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/info.test.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/license.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/license.test.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/notifications.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/notifications.test.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/version.js create mode 100644 packages/backend/src/controllers/api/v1/automatisch/version.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/delete-connection.js create mode 100644 packages/backend/src/controllers/api/v1/connections/delete-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/generate-auth-url.js create mode 100644 packages/backend/src/controllers/api/v1/connections/generate-auth-url.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/get-flows.js create mode 100644 packages/backend/src/controllers/api/v1/connections/get-flows.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/reset-connection.js create mode 100644 packages/backend/src/controllers/api/v1/connections/reset-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/test-connection.js create mode 100644 packages/backend/src/controllers/api/v1/connections/test-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/update-connection.js create mode 100644 packages/backend/src/controllers/api/v1/connections/update-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/connections/verify-connection.js create mode 100644 packages/backend/src/controllers/api/v1/connections/verify-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/executions/get-execution-steps.js create mode 100644 packages/backend/src/controllers/api/v1/executions/get-execution-steps.test.js create mode 100644 packages/backend/src/controllers/api/v1/executions/get-execution.js create mode 100644 packages/backend/src/controllers/api/v1/executions/get-execution.test.js create mode 100644 packages/backend/src/controllers/api/v1/executions/get-executions.js create mode 100644 packages/backend/src/controllers/api/v1/executions/get-executions.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/create-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/create-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/create-step.js create mode 100644 packages/backend/src/controllers/api/v1/flows/create-step.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/delete-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/delete-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/duplicate-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/export-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/export-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/get-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/get-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/get-flows.js create mode 100644 packages/backend/src/controllers/api/v1/flows/get-flows.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/import-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/import-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow-folder.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow-status.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow.test.js create mode 100644 packages/backend/src/controllers/api/v1/folders/create-folder.js create mode 100644 packages/backend/src/controllers/api/v1/folders/create-folder.test.js create mode 100644 packages/backend/src/controllers/api/v1/folders/delete-folder.js create mode 100644 packages/backend/src/controllers/api/v1/folders/delete-folder.test.js create mode 100644 packages/backend/src/controllers/api/v1/folders/get-folders.js create mode 100644 packages/backend/src/controllers/api/v1/folders/get-folders.test.js create mode 100644 packages/backend/src/controllers/api/v1/folders/update-folder.js create mode 100644 packages/backend/src/controllers/api/v1/folders/update-folder.test.js create mode 100644 packages/backend/src/controllers/api/v1/installation/users/create-user.js create mode 100644 packages/backend/src/controllers/api/v1/installation/users/create-user.test.js create mode 100644 packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.js create mode 100644 packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/payment/get-plans.ee.js create mode 100644 packages/backend/src/controllers/api/v1/payment/get-plans.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.js create mode 100644 packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js create mode 100644 packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.js create mode 100644 packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/delete-step.js create mode 100644 packages/backend/src/controllers/api/v1/steps/delete-step.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/get-connection.js create mode 100644 packages/backend/src/controllers/api/v1/steps/get-connection.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/get-previous-steps.js create mode 100644 packages/backend/src/controllers/api/v1/steps/get-previous-steps.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/test-step.js create mode 100644 packages/backend/src/controllers/api/v1/steps/test-step.test.js create mode 100644 packages/backend/src/controllers/api/v1/steps/update-step.js create mode 100644 packages/backend/src/controllers/api/v1/steps/update-step.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/accept-invitation.js create mode 100644 packages/backend/src/controllers/api/v1/users/delete-current-user.js create mode 100644 packages/backend/src/controllers/api/v1/users/delete-current-user.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/forgot-password.js create mode 100644 packages/backend/src/controllers/api/v1/users/forgot-password.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-apps.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-apps.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-current-user.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-current-user.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-invoices.ee.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-invoices.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-subscription.ee.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-subscription.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-user-trial.ee.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-user-trial.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/register-user.ee.js create mode 100644 packages/backend/src/controllers/api/v1/users/register-user.ee.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/reset-password.js create mode 100644 packages/backend/src/controllers/api/v1/users/reset-password.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/update-current-user-password.js create mode 100644 packages/backend/src/controllers/api/v1/users/update-current-user-password.test.js create mode 100644 packages/backend/src/controllers/api/v1/users/update-current-user.js create mode 100644 packages/backend/src/controllers/api/v1/users/update-current-user.test.js create mode 100644 packages/backend/src/controllers/healthcheck/index.js create mode 100644 packages/backend/src/controllers/healthcheck/index.test.js create mode 100644 packages/backend/src/controllers/paddle/webhooks.ee.js create mode 100644 packages/backend/src/controllers/webhooks/handler-by-flow-id.js create mode 100644 packages/backend/src/controllers/webhooks/handler-sync-by-flow-id.js create mode 100644 packages/backend/src/db/migrations/20211005151457_create_users.js create mode 100644 packages/backend/src/db/migrations/20211011120732_create_credentials.js create mode 100644 packages/backend/src/db/migrations/20211014144855_remove_display_name_from_credentials.js create mode 100644 packages/backend/src/db/migrations/20211017104154_rename_credentials_as_connections.js create mode 100644 packages/backend/src/db/migrations/20211106214730_create_steps.js create mode 100644 packages/backend/src/db/migrations/20211122140336_create_flows.js create mode 100644 packages/backend/src/db/migrations/20211122140612_add_flow_id_to_steps.js create mode 100644 packages/backend/src/db/migrations/20220105151725_remove_constraints_from_steps.js create mode 100644 packages/backend/src/db/migrations/20220108141045_add_active_column_to_flows.js create mode 100644 packages/backend/src/db/migrations/20220127141941_add_position_to_steps.js create mode 100644 packages/backend/src/db/migrations/20220205145128_add_status_to_steps.js create mode 100644 packages/backend/src/db/migrations/20220219093113_create_executions.js create mode 100644 packages/backend/src/db/migrations/20220219100800_create_execution_steps.js create mode 100644 packages/backend/src/db/migrations/20220221225128_alter_columns_of_execution_steps.js create mode 100644 packages/backend/src/db/migrations/20220221225537_alter_parameters_column_of_steps.js create mode 100644 packages/backend/src/db/migrations/20220727104324_add_draft_column_to_connections.js create mode 100644 packages/backend/src/db/migrations/20220817121241_add_published_at_column_to_flows.js create mode 100644 packages/backend/src/db/migrations/20220823171017_add_internal_id_to_executions.js create mode 100644 packages/backend/src/db/migrations/20220904160521_add_raw_error_to_execution_steps.js create mode 100644 packages/backend/src/db/migrations/20220928162525_soft-delete-base-model.js create mode 100644 packages/backend/src/db/migrations/20221214184855_add_remote_webhook_id_in_flow.js create mode 100644 packages/backend/src/db/migrations/20230218110748_add_role_to_users.js create mode 100644 packages/backend/src/db/migrations/20230218131824_alter_role_to_not_nullable_for_users.js create mode 100644 packages/backend/src/db/migrations/20230218150517_add_reset_password_token_to_users.js create mode 100644 packages/backend/src/db/migrations/20230218150758_add_reset_password_token_sent_at_to_users.js create mode 100644 packages/backend/src/db/migrations/20230301211751_add_full_name_to_users.js create mode 100644 packages/backend/src/db/migrations/20230303134548_create_payment_plans.js create mode 100644 packages/backend/src/db/migrations/20230303180902_create_usage_data.js create mode 100644 packages/backend/src/db/migrations/20230306103149_alter_consumed_task_count_of_usage_data.js create mode 100644 packages/backend/src/db/migrations/20230318220822_add_trial_expiry_date_to_users.js create mode 100644 packages/backend/src/db/migrations/20230323145809_create_subscriptions.js create mode 100644 packages/backend/src/db/migrations/20230324210051_add_deleted_at_to_subscriptions.js create mode 100644 packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.js create mode 100644 packages/backend/src/db/migrations/20230411203412_add_cancellation_effective_date_to_subscriptions.js create mode 100644 packages/backend/src/db/migrations/20230415134138_drop_payment_plans.js create mode 100644 packages/backend/src/db/migrations/20230609201228_add_webhook_path_in_step.js create mode 100644 packages/backend/src/db/migrations/20230609201909_populate_data_in_webhook_path_in_step.js create mode 100644 packages/backend/src/db/migrations/20230615200200_create_roles.js create mode 100644 packages/backend/src/db/migrations/20230615205857_create_permissions.js create mode 100644 packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.js create mode 100644 packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.js create mode 100644 packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.js create mode 100644 packages/backend/src/db/migrations/20230707094923_create_identities.js create mode 100644 packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.js create mode 100644 packages/backend/src/db/migrations/20230807114158_seed_saml_permissions_to_admin.js create mode 100644 packages/backend/src/db/migrations/20230810124730_create_config.js create mode 100644 packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.js create mode 100644 packages/backend/src/db/migrations/20230811142340_create_saml_auth_providers_role_mappings.js create mode 100644 packages/backend/src/db/migrations/20230812132005_create_app_configs.js create mode 100644 packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.js create mode 100644 packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.js create mode 100644 packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.js create mode 100644 packages/backend/src/db/migrations/20230816173027_make_role_id_not_nullable_in_users.js create mode 100644 packages/backend/src/db/migrations/20230824105813_soft_delete_soft_deleted_user_associations.js create mode 100644 packages/backend/src/db/migrations/20230828134734_convert_permission_conditions_to_array.js create mode 100644 packages/backend/src/db/migrations/20231013094544_convert_user_emails_to_lowercase.js create mode 100644 packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.js create mode 100644 packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.js create mode 100644 packages/backend/src/db/migrations/20240227164849_create_datastore_model.js create mode 100644 packages/backend/src/db/migrations/20240326194638_add_app_key_to_app_auth_clients.js create mode 100644 packages/backend/src/db/migrations/20240326195028_migrate_app_config_id_to_app_key.js create mode 100644 packages/backend/src/db/migrations/20240326195748_remove_app_config_id_from_app_auth_clients.js create mode 100644 packages/backend/src/db/migrations/20240326202110_add_not_nullable_to_app_key_of_app_auth_clients.js create mode 100644 packages/backend/src/db/migrations/20240422130323_create_access_tokens.js create mode 100644 packages/backend/src/db/migrations/20240424100113_add_indexes_to_access_tokens.js create mode 100644 packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js create mode 100644 packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js create mode 100644 packages/backend/src/db/migrations/20240509202750_make_value_column_text_in_datastore.js create mode 100644 packages/backend/src/db/migrations/20240708140250_add_status_to_users.js create mode 100644 packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js create mode 100644 packages/backend/src/db/migrations/20240903110620_make_role_name_unique.js create mode 100644 packages/backend/src/db/migrations/20240904091615_remove_key_column_in_roles.js create mode 100644 packages/backend/src/db/migrations/20240919100138_make_config_single_record.js create mode 100644 packages/backend/src/db/migrations/20241002121145_add_connection_allowed_to_app_configs.js create mode 100644 packages/backend/src/db/migrations/20241009094438_rename_allow_custom_connection_as_custom_connection_allowed_in_app_configs.js create mode 100644 packages/backend/src/db/migrations/20241024130418_make_key_primary_for_app_configs.js create mode 100644 packages/backend/src/db/migrations/20241024131158_remove_id_column_from_app_configs.js create mode 100644 packages/backend/src/db/migrations/20241125141647_rename_saml_auth_providers_role_mappings_as_role_mappings.js create mode 100644 packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js create mode 100644 packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js create mode 100644 packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js create mode 100644 packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js create mode 100644 packages/backend/src/db/migrations/20250124105728_create_folders.js create mode 100644 packages/backend/src/db/migrations/20250131171406_add_folder_id_to_flows.js create mode 100644 packages/backend/src/errors/already-processed.js create mode 100644 packages/backend/src/errors/base.js create mode 100644 packages/backend/src/errors/early-exit.js create mode 100644 packages/backend/src/errors/generate-auth-url.js create mode 100644 packages/backend/src/errors/http.js create mode 100644 packages/backend/src/errors/not-authorized.js create mode 100644 packages/backend/src/errors/quote-exceeded.js create mode 100644 packages/backend/src/helpers/add-authentication-steps.js create mode 100644 packages/backend/src/helpers/add-reconnection-steps.js create mode 100644 packages/backend/src/helpers/allow-installation.js create mode 100644 packages/backend/src/helpers/app-assets-handler.js create mode 100644 packages/backend/src/helpers/app-info-converter.js create mode 100644 packages/backend/src/helpers/authentication.js create mode 100644 packages/backend/src/helpers/authentication.test.js create mode 100644 packages/backend/src/helpers/authorization.js create mode 100644 packages/backend/src/helpers/axios-with-proxy.js create mode 100644 packages/backend/src/helpers/axios-with-proxy.test.js create mode 100644 packages/backend/src/helpers/billing/index.ee.js create mode 100644 packages/backend/src/helpers/billing/paddle.ee.js create mode 100644 packages/backend/src/helpers/billing/plans.ee.js create mode 100644 packages/backend/src/helpers/billing/webhooks.ee.js create mode 100644 packages/backend/src/helpers/check-is-cloud.js create mode 100644 packages/backend/src/helpers/check-is-enterprise.js create mode 100644 packages/backend/src/helpers/check-worker-readiness.js create mode 100644 packages/backend/src/helpers/compile-email.ee.js create mode 100644 packages/backend/src/helpers/compute-parameters.js create mode 100644 packages/backend/src/helpers/compute-parameters.test.js create mode 100644 packages/backend/src/helpers/create-auth-token-by-user-id.js create mode 100644 packages/backend/src/helpers/create-bull-board-handler.js create mode 100644 packages/backend/src/helpers/define-action.js create mode 100644 packages/backend/src/helpers/define-app.js create mode 100644 packages/backend/src/helpers/define-trigger.js create mode 100644 packages/backend/src/helpers/delay-as-milliseconds.js create mode 100644 packages/backend/src/helpers/delay-for-as-milliseconds.js create mode 100644 packages/backend/src/helpers/delay-until-as-milliseconds.js create mode 100644 packages/backend/src/helpers/error-handler.js create mode 100644 packages/backend/src/helpers/export-flow.js create mode 100644 packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.js create mode 100644 packages/backend/src/helpers/get-app.js create mode 100644 packages/backend/src/helpers/global-variable.js create mode 100644 packages/backend/src/helpers/http-client/index.js create mode 100644 packages/backend/src/helpers/import-flow.js create mode 100644 packages/backend/src/helpers/inject-bull-board-handler.js create mode 100644 packages/backend/src/helpers/license.ee.js create mode 100644 packages/backend/src/helpers/logger.js create mode 100644 packages/backend/src/helpers/mailer.ee.js create mode 100644 packages/backend/src/helpers/morgan.js create mode 100644 packages/backend/src/helpers/pagination-rest.js create mode 100644 packages/backend/src/helpers/pagination.js create mode 100644 packages/backend/src/helpers/parse-header-link.js create mode 100644 packages/backend/src/helpers/passport.js create mode 100644 packages/backend/src/helpers/permission-catalog.ee.js create mode 100644 packages/backend/src/helpers/remove-job-configuration.js create mode 100644 packages/backend/src/helpers/renderer.js create mode 100644 packages/backend/src/helpers/sentry.ee.js create mode 100644 packages/backend/src/helpers/telemetry/index.js create mode 100644 packages/backend/src/helpers/telemetry/instance-id.js create mode 100644 packages/backend/src/helpers/telemetry/organization-id.js create mode 100644 packages/backend/src/helpers/user-ability.js create mode 100644 packages/backend/src/helpers/user-ability.test.js create mode 100644 packages/backend/src/helpers/web-ui-handler.js create mode 100644 packages/backend/src/helpers/webhook-handler-sync.js create mode 100644 packages/backend/src/helpers/webhook-handler.js create mode 100644 packages/backend/src/jobs/delete-user.ee.js create mode 100644 packages/backend/src/jobs/execute-action.js create mode 100644 packages/backend/src/jobs/execute-flow.js create mode 100644 packages/backend/src/jobs/execute-trigger.js create mode 100644 packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js create mode 100644 packages/backend/src/jobs/send-email.js create mode 100644 packages/backend/src/models/__snapshots__/access-token.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/app-config.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/app.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/config.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/connection.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/datastore.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/execution-step.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/execution.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/flow.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/folder.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/identity.ee.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/oauth-client.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/permission.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/role-mapping.ee.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/role.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/saml-auth-provider.ee.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/saml-auth-providers-role-mapping.ee.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/step.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/subscription.ee.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/usage-data.ee.test.js.snap create mode 100644 packages/backend/src/models/__snapshots__/user.test.js.snap create mode 100644 packages/backend/src/models/access-token.js create mode 100644 packages/backend/src/models/access-token.test.js create mode 100644 packages/backend/src/models/app-config.js create mode 100644 packages/backend/src/models/app-config.test.js create mode 100644 packages/backend/src/models/app.js create mode 100644 packages/backend/src/models/app.test.js create mode 100644 packages/backend/src/models/base.js create mode 100644 packages/backend/src/models/config.js create mode 100644 packages/backend/src/models/config.test.js create mode 100644 packages/backend/src/models/connection.js create mode 100644 packages/backend/src/models/connection.test.js create mode 100644 packages/backend/src/models/datastore.js create mode 100644 packages/backend/src/models/datastore.test.js create mode 100644 packages/backend/src/models/execution-step.js create mode 100644 packages/backend/src/models/execution-step.test.js create mode 100644 packages/backend/src/models/execution.js create mode 100644 packages/backend/src/models/execution.test.js create mode 100644 packages/backend/src/models/flow.js create mode 100644 packages/backend/src/models/flow.test.js create mode 100644 packages/backend/src/models/folder.js create mode 100644 packages/backend/src/models/folder.test.js create mode 100644 packages/backend/src/models/identity.ee.js create mode 100644 packages/backend/src/models/identity.ee.test.js create mode 100644 packages/backend/src/models/oauth-client.js create mode 100644 packages/backend/src/models/oauth-client.test.js create mode 100644 packages/backend/src/models/permission.js create mode 100644 packages/backend/src/models/permission.test.js create mode 100644 packages/backend/src/models/query-builder.js create mode 100644 packages/backend/src/models/role-mapping.ee.js create mode 100644 packages/backend/src/models/role-mapping.ee.test.js create mode 100644 packages/backend/src/models/role.js create mode 100644 packages/backend/src/models/role.test.js create mode 100644 packages/backend/src/models/saml-auth-provider.ee.js create mode 100644 packages/backend/src/models/saml-auth-provider.ee.test.js create mode 100644 packages/backend/src/models/step.js create mode 100644 packages/backend/src/models/step.test.js create mode 100644 packages/backend/src/models/subscription.ee.js create mode 100644 packages/backend/src/models/subscription.ee.test.js create mode 100644 packages/backend/src/models/usage-data.ee.js create mode 100644 packages/backend/src/models/usage-data.ee.test.js create mode 100644 packages/backend/src/models/user.js create mode 100644 packages/backend/src/models/user.test.js create mode 100644 packages/backend/src/queues/action.js create mode 100644 packages/backend/src/queues/delete-user.ee.js create mode 100644 packages/backend/src/queues/email.js create mode 100644 packages/backend/src/queues/flow.js create mode 100644 packages/backend/src/queues/index.js create mode 100644 packages/backend/src/queues/queue.js create mode 100644 packages/backend/src/queues/remove-cancelled-subscriptions.ee.js create mode 100644 packages/backend/src/queues/trigger.js create mode 100644 packages/backend/src/routes/api/v1/access-tokens.js create mode 100644 packages/backend/src/routes/api/v1/admin/apps.ee.js create mode 100644 packages/backend/src/routes/api/v1/admin/config.ee.js create mode 100644 packages/backend/src/routes/api/v1/admin/permissions.ee.js create mode 100644 packages/backend/src/routes/api/v1/admin/roles.ee.js create mode 100644 packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js create mode 100644 packages/backend/src/routes/api/v1/admin/users.ee.js create mode 100644 packages/backend/src/routes/api/v1/apps.js create mode 100644 packages/backend/src/routes/api/v1/automatisch.js create mode 100644 packages/backend/src/routes/api/v1/connections.js create mode 100644 packages/backend/src/routes/api/v1/executions.js create mode 100644 packages/backend/src/routes/api/v1/flows.js create mode 100644 packages/backend/src/routes/api/v1/folders.js create mode 100644 packages/backend/src/routes/api/v1/installation/users.js create mode 100644 packages/backend/src/routes/api/v1/payment.ee.js create mode 100644 packages/backend/src/routes/api/v1/saml-auth-providers.ee.js create mode 100644 packages/backend/src/routes/api/v1/steps.js create mode 100644 packages/backend/src/routes/api/v1/users.js create mode 100644 packages/backend/src/routes/healthcheck.js create mode 100644 packages/backend/src/routes/index.js create mode 100644 packages/backend/src/routes/paddle.ee.js create mode 100644 packages/backend/src/routes/webhooks.js create mode 100644 packages/backend/src/serializers/action.js create mode 100644 packages/backend/src/serializers/action.test.js create mode 100644 packages/backend/src/serializers/admin-saml-auth-provider.ee.js create mode 100644 packages/backend/src/serializers/admin-saml-auth-provider.ee.test.js create mode 100644 packages/backend/src/serializers/admin/user.js create mode 100644 packages/backend/src/serializers/admin/user.test.js create mode 100644 packages/backend/src/serializers/app-config.js create mode 100644 packages/backend/src/serializers/app-config.test.js create mode 100644 packages/backend/src/serializers/app.js create mode 100644 packages/backend/src/serializers/app.test.js create mode 100644 packages/backend/src/serializers/auth.js create mode 100644 packages/backend/src/serializers/auth.test.js create mode 100644 packages/backend/src/serializers/config.js create mode 100644 packages/backend/src/serializers/config.test.js create mode 100644 packages/backend/src/serializers/connection.js create mode 100644 packages/backend/src/serializers/connection.test.js create mode 100644 packages/backend/src/serializers/execution-step.js create mode 100644 packages/backend/src/serializers/execution-step.test.js create mode 100644 packages/backend/src/serializers/execution.js create mode 100644 packages/backend/src/serializers/execution.test.js create mode 100644 packages/backend/src/serializers/flow.js create mode 100644 packages/backend/src/serializers/flow.test.js create mode 100644 packages/backend/src/serializers/folder.js create mode 100644 packages/backend/src/serializers/folder.test.js create mode 100644 packages/backend/src/serializers/index.js create mode 100644 packages/backend/src/serializers/oauth-client.js create mode 100644 packages/backend/src/serializers/oauth-client.test.js create mode 100644 packages/backend/src/serializers/permission.js create mode 100644 packages/backend/src/serializers/permission.test.js create mode 100644 packages/backend/src/serializers/role-mapping.ee.js create mode 100644 packages/backend/src/serializers/role.js create mode 100644 packages/backend/src/serializers/role.test.js create mode 100644 packages/backend/src/serializers/saml-auth-provider.ee.js create mode 100644 packages/backend/src/serializers/saml-auth-provider.ee.test.js create mode 100644 packages/backend/src/serializers/step.js create mode 100644 packages/backend/src/serializers/step.test.js create mode 100644 packages/backend/src/serializers/subscription.ee.js create mode 100644 packages/backend/src/serializers/subscription.ee.test.js create mode 100644 packages/backend/src/serializers/trigger.js create mode 100644 packages/backend/src/serializers/trigger.test.js create mode 100644 packages/backend/src/serializers/user-app.js create mode 100644 packages/backend/src/serializers/user.js create mode 100644 packages/backend/src/serializers/user.test.js create mode 100644 packages/backend/src/server.js create mode 100644 packages/backend/src/services/action.js create mode 100644 packages/backend/src/services/flow.js create mode 100644 packages/backend/src/services/test-run.js create mode 100644 packages/backend/src/services/trigger.js create mode 100644 packages/backend/src/views/emails/invitation-instructions.hbs create mode 100644 packages/backend/src/views/emails/reset-password-instructions.ee.hbs create mode 100644 packages/backend/src/worker.js create mode 100644 packages/backend/src/workers/action.js create mode 100644 packages/backend/src/workers/delete-user.ee.js create mode 100644 packages/backend/src/workers/email.js create mode 100644 packages/backend/src/workers/flow.js create mode 100644 packages/backend/src/workers/index.js create mode 100644 packages/backend/src/workers/remove-cancelled-subscriptions.ee.js create mode 100644 packages/backend/src/workers/trigger.js create mode 100644 packages/backend/src/workers/worker.js create mode 100644 packages/backend/test/assertions/to-require-property.js create mode 100644 packages/backend/test/factories/access-token.js create mode 100644 packages/backend/test/factories/app-config.js create mode 100644 packages/backend/test/factories/app.js create mode 100644 packages/backend/test/factories/config.js create mode 100644 packages/backend/test/factories/connection.js create mode 100644 packages/backend/test/factories/execution-step.js create mode 100644 packages/backend/test/factories/execution.js create mode 100644 packages/backend/test/factories/flow.js create mode 100644 packages/backend/test/factories/folder.js create mode 100644 packages/backend/test/factories/identity.js create mode 100644 packages/backend/test/factories/oauth-client.js create mode 100644 packages/backend/test/factories/permission.js create mode 100644 packages/backend/test/factories/role-mapping.js create mode 100644 packages/backend/test/factories/role.js create mode 100644 packages/backend/test/factories/saml-auth-provider.ee.js create mode 100644 packages/backend/test/factories/step.js create mode 100644 packages/backend/test/factories/subscription.js create mode 100644 packages/backend/test/factories/usage-data.js create mode 100644 packages/backend/test/factories/user.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/config/update.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/permissions/get-permissions-catalog.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/roles/create-role.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/roles/get-role.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/roles/get-roles.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/roles/update-role.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js create mode 100644 packages/backend/test/mocks/rest/api/v1/admin/users/update-user.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/create-connection.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-action-substeps.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-actions.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-app.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-apps.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-auth.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-config.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-connections.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-trigger-substeps.js create mode 100644 packages/backend/test/mocks/rest/api/v1/apps/get-triggers.js create mode 100644 packages/backend/test/mocks/rest/api/v1/automatisch/config.js create mode 100644 packages/backend/test/mocks/rest/api/v1/automatisch/info.js create mode 100644 packages/backend/test/mocks/rest/api/v1/automatisch/license.js create mode 100644 packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js create mode 100644 packages/backend/test/mocks/rest/api/v1/connections/update-connection.js create mode 100644 packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js create mode 100644 packages/backend/test/mocks/rest/api/v1/executions/get-execution.js create mode 100644 packages/backend/test/mocks/rest/api/v1/executions/get-executions.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/create-flow.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/create-step.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/export-flow.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/get-flow.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/get-flows.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/import-flow.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js create mode 100644 packages/backend/test/mocks/rest/api/v1/folders/create-folder.js create mode 100644 packages/backend/test/mocks/rest/api/v1/folders/get-folders.js create mode 100644 packages/backend/test/mocks/rest/api/v1/folders/update-folder.js create mode 100644 packages/backend/test/mocks/rest/api/v1/payment/get-paddle-info.js create mode 100644 packages/backend/test/mocks/rest/api/v1/payment/get-plans.js create mode 100644 packages/backend/test/mocks/rest/api/v1/saml-auth-providers/get-saml-auth-providers.js create mode 100644 packages/backend/test/mocks/rest/api/v1/steps/create-dynamic-fields.js create mode 100644 packages/backend/test/mocks/rest/api/v1/steps/get-connection.js create mode 100644 packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js create mode 100644 packages/backend/test/mocks/rest/api/v1/steps/test-step.js create mode 100644 packages/backend/test/mocks/rest/api/v1/steps/update-step.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/create-user.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/get-apps.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/get-current-user.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/get-invoices.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/get-subscription.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/get-user-trial.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/update-current-user-password.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/update-current-user.js create mode 100644 packages/backend/test/setup/check-env-file.js create mode 100644 packages/backend/test/setup/global-hooks.js create mode 100644 packages/backend/test/setup/insert-assertions.js create mode 100644 packages/backend/test/setup/prepare-test-env.js create mode 100644 packages/backend/vitest.config.js create mode 100644 packages/backend/yarn.lock create mode 100644 packages/docs/.gitignore create mode 100644 packages/docs/package.json create mode 100644 packages/docs/pages/.vitepress/config.js create mode 100644 packages/docs/pages/.vitepress/theme/CustomLayout.vue create mode 100644 packages/docs/pages/.vitepress/theme/custom.css create mode 100644 packages/docs/pages/.vitepress/theme/index.js create mode 100644 packages/docs/pages/advanced/configuration.md create mode 100644 packages/docs/pages/advanced/credentials.md create mode 100644 packages/docs/pages/advanced/telemetry.md create mode 100644 packages/docs/pages/apps/airtable/actions.md create mode 100644 packages/docs/pages/apps/airtable/connection.md create mode 100644 packages/docs/pages/apps/anthropic/actions.md create mode 100644 packages/docs/pages/apps/anthropic/connection.md create mode 100644 packages/docs/pages/apps/appwrite/connection.md create mode 100644 packages/docs/pages/apps/appwrite/triggers.md create mode 100644 packages/docs/pages/apps/brave-search/actions.md create mode 100644 packages/docs/pages/apps/brave-search/connection.md create mode 100644 packages/docs/pages/apps/carbone/actions.md create mode 100644 packages/docs/pages/apps/carbone/connection.md create mode 100644 packages/docs/pages/apps/clickup/actions.md create mode 100644 packages/docs/pages/apps/clickup/connection.md create mode 100644 packages/docs/pages/apps/clickup/triggers.md create mode 100644 packages/docs/pages/apps/cryptography/actions.md create mode 100644 packages/docs/pages/apps/cryptography/connection.md create mode 100644 packages/docs/pages/apps/datastore/actions.md create mode 100644 packages/docs/pages/apps/datastore/connection.md create mode 100644 packages/docs/pages/apps/deepl/actions.md create mode 100644 packages/docs/pages/apps/deepl/connection.md create mode 100644 packages/docs/pages/apps/delay/actions.md create mode 100644 packages/docs/pages/apps/delay/connection.md create mode 100644 packages/docs/pages/apps/discord/actions.md create mode 100644 packages/docs/pages/apps/discord/connection.md create mode 100644 packages/docs/pages/apps/disqus/connection.md create mode 100644 packages/docs/pages/apps/disqus/triggers.md create mode 100644 packages/docs/pages/apps/dropbox/actions.md create mode 100644 packages/docs/pages/apps/dropbox/connection.md create mode 100644 packages/docs/pages/apps/filter/actions.md create mode 100644 packages/docs/pages/apps/filter/connection.md create mode 100644 packages/docs/pages/apps/flickr/connection.md create mode 100644 packages/docs/pages/apps/flickr/triggers.md create mode 100644 packages/docs/pages/apps/formatter/actions.md create mode 100644 packages/docs/pages/apps/formatter/connection.md create mode 100644 packages/docs/pages/apps/freescout/connection.md create mode 100644 packages/docs/pages/apps/freescout/triggers.md create mode 100644 packages/docs/pages/apps/ghost/connection.md create mode 100644 packages/docs/pages/apps/ghost/triggers.md create mode 100644 packages/docs/pages/apps/github/actions.md create mode 100644 packages/docs/pages/apps/github/connection.md create mode 100644 packages/docs/pages/apps/github/triggers.md create mode 100644 packages/docs/pages/apps/gitlab/connection.md create mode 100644 packages/docs/pages/apps/gitlab/triggers.md create mode 100644 packages/docs/pages/apps/google-calendar/connection.md create mode 100644 packages/docs/pages/apps/google-calendar/triggers.md create mode 100644 packages/docs/pages/apps/google-drive/connection.md create mode 100644 packages/docs/pages/apps/google-drive/triggers.md create mode 100644 packages/docs/pages/apps/google-forms/connection.md create mode 100644 packages/docs/pages/apps/google-forms/triggers.md create mode 100644 packages/docs/pages/apps/google-sheets/actions.md create mode 100644 packages/docs/pages/apps/google-sheets/connection.md create mode 100644 packages/docs/pages/apps/google-sheets/triggers.md create mode 100644 packages/docs/pages/apps/google-tasks/actions.md create mode 100644 packages/docs/pages/apps/google-tasks/connection.md create mode 100644 packages/docs/pages/apps/google-tasks/triggers.md create mode 100644 packages/docs/pages/apps/http-request/actions.md create mode 100644 packages/docs/pages/apps/http-request/connection.md create mode 100644 packages/docs/pages/apps/hubspot/actions.md create mode 100644 packages/docs/pages/apps/hubspot/connection.md create mode 100644 packages/docs/pages/apps/invoice-ninja/actions.md create mode 100644 packages/docs/pages/apps/invoice-ninja/connection.md create mode 100644 packages/docs/pages/apps/invoice-ninja/triggers.md create mode 100644 packages/docs/pages/apps/jotform/connection.md create mode 100644 packages/docs/pages/apps/jotform/triggers.md create mode 100644 packages/docs/pages/apps/mailchimp/actions.md create mode 100644 packages/docs/pages/apps/mailchimp/connection.md create mode 100644 packages/docs/pages/apps/mailchimp/triggers.md create mode 100644 packages/docs/pages/apps/mailerlite/connection.md create mode 100644 packages/docs/pages/apps/mailerlite/triggers.md create mode 100644 packages/docs/pages/apps/mattermost/actions.md create mode 100644 packages/docs/pages/apps/mattermost/connection.md create mode 100644 packages/docs/pages/apps/miro/actions.md create mode 100644 packages/docs/pages/apps/miro/connection.md create mode 100644 packages/docs/pages/apps/mistral-ai/actions.md create mode 100644 packages/docs/pages/apps/mistral-ai/connection.md create mode 100644 packages/docs/pages/apps/notion/actions.md create mode 100644 packages/docs/pages/apps/notion/connection.md create mode 100644 packages/docs/pages/apps/notion/triggers.md create mode 100644 packages/docs/pages/apps/ntfy/actions.md create mode 100644 packages/docs/pages/apps/ntfy/connection.md create mode 100644 packages/docs/pages/apps/odoo/actions.md create mode 100644 packages/docs/pages/apps/odoo/connection.md create mode 100644 packages/docs/pages/apps/openai/actions.md create mode 100644 packages/docs/pages/apps/openai/connection.md create mode 100644 packages/docs/pages/apps/openrouter/actions.md create mode 100644 packages/docs/pages/apps/openrouter/connection.md create mode 100644 packages/docs/pages/apps/perplexity/actions.md create mode 100644 packages/docs/pages/apps/perplexity/connection.md create mode 100644 packages/docs/pages/apps/pipedrive/actions.md create mode 100644 packages/docs/pages/apps/pipedrive/connection.md create mode 100644 packages/docs/pages/apps/pipedrive/triggers.md create mode 100644 packages/docs/pages/apps/placetel/connection.md create mode 100644 packages/docs/pages/apps/placetel/triggers.md create mode 100644 packages/docs/pages/apps/postgresql/actions.md create mode 100644 packages/docs/pages/apps/postgresql/connection.md create mode 100644 packages/docs/pages/apps/pushover/actions.md create mode 100644 packages/docs/pages/apps/pushover/connection.md create mode 100644 packages/docs/pages/apps/reddit/actions.md create mode 100644 packages/docs/pages/apps/reddit/connection.md create mode 100644 packages/docs/pages/apps/reddit/triggers.md create mode 100644 packages/docs/pages/apps/removebg/actions.md create mode 100644 packages/docs/pages/apps/removebg/connection.md create mode 100644 packages/docs/pages/apps/rss/connection.md create mode 100644 packages/docs/pages/apps/rss/triggers.md create mode 100644 packages/docs/pages/apps/salesforce/actions.md create mode 100644 packages/docs/pages/apps/salesforce/connection.md create mode 100644 packages/docs/pages/apps/salesforce/triggers.md create mode 100644 packages/docs/pages/apps/scheduler/connection.md create mode 100644 packages/docs/pages/apps/scheduler/triggers.md create mode 100644 packages/docs/pages/apps/signalwire/actions.md create mode 100644 packages/docs/pages/apps/signalwire/connection.md create mode 100644 packages/docs/pages/apps/signalwire/triggers.md create mode 100644 packages/docs/pages/apps/slack/actions.md create mode 100644 packages/docs/pages/apps/slack/connection.md create mode 100644 packages/docs/pages/apps/smtp/actions.md create mode 100644 packages/docs/pages/apps/smtp/connection.md create mode 100644 packages/docs/pages/apps/spotify/actions.md create mode 100644 packages/docs/pages/apps/spotify/connection.md create mode 100644 packages/docs/pages/apps/strava/actions.md create mode 100644 packages/docs/pages/apps/strava/connection.md create mode 100644 packages/docs/pages/apps/stripe/connection.md create mode 100644 packages/docs/pages/apps/stripe/triggers.md create mode 100644 packages/docs/pages/apps/telegram-bot/actions.md create mode 100644 packages/docs/pages/apps/telegram-bot/connection.md create mode 100644 packages/docs/pages/apps/todoist/actions.md create mode 100644 packages/docs/pages/apps/todoist/connection.md create mode 100644 packages/docs/pages/apps/todoist/triggers.md create mode 100644 packages/docs/pages/apps/together-ai/actions.md create mode 100644 packages/docs/pages/apps/together-ai/connection.md create mode 100644 packages/docs/pages/apps/trello/actions.md create mode 100644 packages/docs/pages/apps/trello/connection.md create mode 100644 packages/docs/pages/apps/twilio/actions.md create mode 100644 packages/docs/pages/apps/twilio/connection.md create mode 100644 packages/docs/pages/apps/twilio/triggers.md create mode 100644 packages/docs/pages/apps/twitter/actions.md create mode 100644 packages/docs/pages/apps/twitter/connection.md create mode 100644 packages/docs/pages/apps/twitter/triggers.md create mode 100644 packages/docs/pages/apps/typeform/connection.md create mode 100644 packages/docs/pages/apps/typeform/triggers.md create mode 100644 packages/docs/pages/apps/virtualq/actions.md create mode 100644 packages/docs/pages/apps/virtualq/connection.md create mode 100644 packages/docs/pages/apps/vtiger-crm/actions.md create mode 100644 packages/docs/pages/apps/vtiger-crm/connection.md create mode 100644 packages/docs/pages/apps/vtiger-crm/triggers.md create mode 100644 packages/docs/pages/apps/webhooks/connection.md create mode 100644 packages/docs/pages/apps/webhooks/triggers.md create mode 100644 packages/docs/pages/apps/wordpress/connection.md create mode 100644 packages/docs/pages/apps/wordpress/triggers.md create mode 100644 packages/docs/pages/apps/xero/connection.md create mode 100644 packages/docs/pages/apps/xero/triggers.md create mode 100644 packages/docs/pages/apps/you-need-a-budget/connection.md create mode 100644 packages/docs/pages/apps/you-need-a-budget/triggers.md create mode 100644 packages/docs/pages/apps/youtube/connection.md create mode 100644 packages/docs/pages/apps/youtube/triggers.md create mode 100644 packages/docs/pages/apps/zendesk/actions.md create mode 100644 packages/docs/pages/apps/zendesk/connection.md create mode 100644 packages/docs/pages/apps/zendesk/triggers.md create mode 100644 packages/docs/pages/assets/flow-900.png create mode 100644 packages/docs/pages/build-integrations/actions.md create mode 100644 packages/docs/pages/build-integrations/app.md create mode 100644 packages/docs/pages/build-integrations/auth.md create mode 100644 packages/docs/pages/build-integrations/examples.md create mode 100644 packages/docs/pages/build-integrations/folder-structure.md create mode 100644 packages/docs/pages/build-integrations/global-variable.md create mode 100644 packages/docs/pages/build-integrations/triggers.md create mode 100644 packages/docs/pages/components/CustomListing.vue create mode 100644 packages/docs/pages/contributing/contribution-guide.md create mode 100644 packages/docs/pages/contributing/development-setup.md create mode 100644 packages/docs/pages/contributing/repository-structure.md create mode 100644 packages/docs/pages/guide/available-apps.md create mode 100644 packages/docs/pages/guide/create-flow.md create mode 100644 packages/docs/pages/guide/installation.md create mode 100644 packages/docs/pages/guide/key-concepts.md create mode 100644 packages/docs/pages/guide/request-integration.md create mode 100644 packages/docs/pages/index.md create mode 100644 packages/docs/pages/other/community.md create mode 100644 packages/docs/pages/other/license.md create mode 100644 packages/docs/pages/public/example-app/cat.svg create mode 100644 packages/docs/pages/public/favicons/airtable.svg create mode 100644 packages/docs/pages/public/favicons/anthropic.svg create mode 100644 packages/docs/pages/public/favicons/appwrite.svg create mode 100644 packages/docs/pages/public/favicons/brave-search.svg create mode 100644 packages/docs/pages/public/favicons/carbone.svg create mode 100644 packages/docs/pages/public/favicons/clickup.svg create mode 100644 packages/docs/pages/public/favicons/cryptography.svg create mode 100644 packages/docs/pages/public/favicons/datastore.svg create mode 100644 packages/docs/pages/public/favicons/deepl.svg create mode 100644 packages/docs/pages/public/favicons/delay.svg create mode 100644 packages/docs/pages/public/favicons/discord.svg create mode 100644 packages/docs/pages/public/favicons/disqus.svg create mode 100644 packages/docs/pages/public/favicons/dropbox.svg create mode 100644 packages/docs/pages/public/favicons/filter.svg create mode 100644 packages/docs/pages/public/favicons/flickr.svg create mode 100644 packages/docs/pages/public/favicons/formatter.svg create mode 100644 packages/docs/pages/public/favicons/freescout.svg create mode 100644 packages/docs/pages/public/favicons/ghost.svg create mode 100644 packages/docs/pages/public/favicons/github.svg create mode 100644 packages/docs/pages/public/favicons/gitlab.svg create mode 100644 packages/docs/pages/public/favicons/google-calendar.svg create mode 100644 packages/docs/pages/public/favicons/google-drive.svg create mode 100644 packages/docs/pages/public/favicons/google-forms.svg create mode 100644 packages/docs/pages/public/favicons/google-sheets.svg create mode 100644 packages/docs/pages/public/favicons/google-tasks.svg create mode 100644 packages/docs/pages/public/favicons/http-request.svg create mode 100644 packages/docs/pages/public/favicons/hubspot.svg create mode 100644 packages/docs/pages/public/favicons/invoice-ninja.svg create mode 100644 packages/docs/pages/public/favicons/jotform.svg create mode 100644 packages/docs/pages/public/favicons/mailchimp.svg create mode 100644 packages/docs/pages/public/favicons/mailerlite.svg create mode 100644 packages/docs/pages/public/favicons/mattermost.svg create mode 100644 packages/docs/pages/public/favicons/miro.svg create mode 100644 packages/docs/pages/public/favicons/mistral-ai.svg create mode 100644 packages/docs/pages/public/favicons/notion.svg create mode 100644 packages/docs/pages/public/favicons/ntfy.svg create mode 100644 packages/docs/pages/public/favicons/odoo.svg create mode 100644 packages/docs/pages/public/favicons/openai.svg create mode 100644 packages/docs/pages/public/favicons/openrouter.svg create mode 100644 packages/docs/pages/public/favicons/perplexity.svg create mode 100644 packages/docs/pages/public/favicons/pipedrive.svg create mode 100644 packages/docs/pages/public/favicons/placetel.svg create mode 100644 packages/docs/pages/public/favicons/postgres.svg create mode 100644 packages/docs/pages/public/favicons/pushover.svg create mode 100644 packages/docs/pages/public/favicons/reddit.svg create mode 100644 packages/docs/pages/public/favicons/removebg.svg create mode 100644 packages/docs/pages/public/favicons/rss.svg create mode 100644 packages/docs/pages/public/favicons/salesforce.svg create mode 100644 packages/docs/pages/public/favicons/scheduler.svg create mode 100644 packages/docs/pages/public/favicons/signalwire.svg create mode 100644 packages/docs/pages/public/favicons/slack.svg create mode 100644 packages/docs/pages/public/favicons/smtp.svg create mode 100644 packages/docs/pages/public/favicons/spotify.svg create mode 100644 packages/docs/pages/public/favicons/strava.svg create mode 100644 packages/docs/pages/public/favicons/stripe.svg create mode 100644 packages/docs/pages/public/favicons/telegram-bot.svg create mode 100644 packages/docs/pages/public/favicons/todoist.svg create mode 100644 packages/docs/pages/public/favicons/together-ai.svg create mode 100644 packages/docs/pages/public/favicons/trello.svg create mode 100644 packages/docs/pages/public/favicons/twilio.svg create mode 100644 packages/docs/pages/public/favicons/twitter.svg create mode 100644 packages/docs/pages/public/favicons/typeform.svg create mode 100644 packages/docs/pages/public/favicons/virtualq.svg create mode 100644 packages/docs/pages/public/favicons/vtiger-crm.svg create mode 100644 packages/docs/pages/public/favicons/webhooks.svg create mode 100644 packages/docs/pages/public/favicons/wordpress.svg create mode 100644 packages/docs/pages/public/favicons/xero.svg create mode 100644 packages/docs/pages/public/favicons/you-need-a-budget.svg create mode 100644 packages/docs/pages/public/favicons/youtube.svg create mode 100644 packages/docs/pages/public/favicons/zendesk.svg create mode 100644 packages/docs/yarn.lock create mode 100644 packages/e2e-tests/.env-example create mode 100644 packages/e2e-tests/.eslintignore create mode 100644 packages/e2e-tests/.eslintrc.json create mode 100644 packages/e2e-tests/.gitignore create mode 100644 packages/e2e-tests/README.md create mode 100644 packages/e2e-tests/fixtures/accept-invitation-page.js create mode 100644 packages/e2e-tests/fixtures/admin-setup-page.js create mode 100644 packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js create mode 100644 packages/e2e-tests/fixtures/admin/application-settings-page.js create mode 100644 packages/e2e-tests/fixtures/admin/applications-page.js create mode 100644 packages/e2e-tests/fixtures/admin/create-role-page.js create mode 100644 packages/e2e-tests/fixtures/admin/create-user-page.js create mode 100644 packages/e2e-tests/fixtures/admin/delete-role-modal.js create mode 100644 packages/e2e-tests/fixtures/admin/delete-user-modal.js create mode 100644 packages/e2e-tests/fixtures/admin/edit-role-page.js create mode 100644 packages/e2e-tests/fixtures/admin/edit-user-page.js create mode 100644 packages/e2e-tests/fixtures/admin/index.js create mode 100644 packages/e2e-tests/fixtures/admin/role-conditions-modal.js create mode 100644 packages/e2e-tests/fixtures/admin/roles-page.js create mode 100644 packages/e2e-tests/fixtures/admin/users-page.js create mode 100644 packages/e2e-tests/fixtures/applications-modal.js create mode 100644 packages/e2e-tests/fixtures/applications-page.js create mode 100644 packages/e2e-tests/fixtures/apps/github/add-github-connection-modal.js create mode 100644 packages/e2e-tests/fixtures/apps/github/github-page.js create mode 100644 packages/e2e-tests/fixtures/apps/github/github-popup.js create mode 100644 packages/e2e-tests/fixtures/apps/mattermost/add-mattermost-connection-modal.js create mode 100644 packages/e2e-tests/fixtures/authenticated-page.js create mode 100644 packages/e2e-tests/fixtures/base-page.js create mode 100644 packages/e2e-tests/fixtures/connections-page.js create mode 100644 packages/e2e-tests/fixtures/executions-page.js create mode 100644 packages/e2e-tests/fixtures/flow-editor-page.js create mode 100644 packages/e2e-tests/fixtures/index.js create mode 100644 packages/e2e-tests/fixtures/login-page.js create mode 100644 packages/e2e-tests/fixtures/my-profile-page.js create mode 100644 packages/e2e-tests/fixtures/postgres-config.js create mode 100644 packages/e2e-tests/fixtures/user-interface-page.js create mode 100644 packages/e2e-tests/helpers/auth-api-helper.js create mode 100644 packages/e2e-tests/helpers/db-helpers.js create mode 100644 packages/e2e-tests/helpers/flow-api-helper.js create mode 100644 packages/e2e-tests/helpers/user-api-helper.js create mode 100644 packages/e2e-tests/knexfile.js create mode 100644 packages/e2e-tests/license-server-with-mock.js create mode 100644 packages/e2e-tests/package.json create mode 100644 packages/e2e-tests/playwright.config.js create mode 100644 packages/e2e-tests/tests/admin-setup/admin.setup.js create mode 100644 packages/e2e-tests/tests/admin/applications.spec.js create mode 100644 packages/e2e-tests/tests/admin/manage-roles.spec.js create mode 100644 packages/e2e-tests/tests/admin/manage-users.spec.js create mode 100644 packages/e2e-tests/tests/admin/role-conditions.spec.js create mode 100644 packages/e2e-tests/tests/app-integrations/github.spec.js create mode 100644 packages/e2e-tests/tests/app-integrations/webhook.spec.js create mode 100644 packages/e2e-tests/tests/apps/list-apps.spec.js create mode 100644 packages/e2e-tests/tests/authentication/login.spec.js create mode 100644 packages/e2e-tests/tests/connections/create-connection.spec.js create mode 100644 packages/e2e-tests/tests/connections/enabled-pop-up-reminder.spec.js create mode 100644 packages/e2e-tests/tests/executions/display-execution.spec.js create mode 100644 packages/e2e-tests/tests/executions/list-executions.spec.js create mode 100644 packages/e2e-tests/tests/flow-editor/create-flow.spec.js create mode 100644 packages/e2e-tests/tests/global.teardown.js create mode 100644 packages/e2e-tests/tests/my-profile/profile-updates.spec.js create mode 100644 packages/e2e-tests/tests/user-interface/user-interface-configuration.spec.js create mode 100644 packages/e2e-tests/tests/user-invitation/invitation.spec.js create mode 100644 packages/e2e-tests/yarn.lock create mode 100644 packages/web/.env-example create mode 100644 packages/web/.eslintignore create mode 100644 packages/web/.eslintrc.js create mode 100644 packages/web/.gitignore create mode 100644 packages/web/README.md create mode 100644 packages/web/index.js create mode 100644 packages/web/jsconfig.json create mode 100644 packages/web/package.json create mode 100644 packages/web/public/browser-tab.ico create mode 100644 packages/web/public/fonts/Inter-Bold.ttf create mode 100644 packages/web/public/fonts/Inter-Medium.ttf create mode 100644 packages/web/public/fonts/Inter-Regular.ttf create mode 100644 packages/web/public/index.html create mode 100644 packages/web/public/robots.txt create mode 100644 packages/web/src/adminSettingsRoutes.jsx create mode 100644 packages/web/src/components/AcceptInvitationForm/index.jsx create mode 100644 packages/web/src/components/AccountDropdownMenu/index.jsx create mode 100644 packages/web/src/components/AddAppConnection/index.jsx create mode 100644 packages/web/src/components/AddAppConnection/style.js create mode 100644 packages/web/src/components/AddNewAppConnection/index.jsx create mode 100644 packages/web/src/components/AdminApplicationCreateOAuthClient/index.jsx create mode 100644 packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx create mode 100644 packages/web/src/components/AdminApplicationOAuthClientDialog/style.js create mode 100644 packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/index.jsx create mode 100644 packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/style.js create mode 100644 packages/web/src/components/AdminApplicationOAuthClients/index.jsx create mode 100644 packages/web/src/components/AdminApplicationSettings/index.jsx create mode 100644 packages/web/src/components/AdminApplicationSettings/style.js create mode 100644 packages/web/src/components/AdminApplicationUpdateOAuthClient/index.jsx create mode 100644 packages/web/src/components/AdminSettingsLayout/Footer/index.jsx create mode 100644 packages/web/src/components/AdminSettingsLayout/index.jsx create mode 100644 packages/web/src/components/AppBar/index.jsx create mode 100644 packages/web/src/components/AppBar/style.js create mode 100644 packages/web/src/components/AppConnectionContextMenu/index.jsx create mode 100644 packages/web/src/components/AppConnectionRow/index.jsx create mode 100644 packages/web/src/components/AppConnectionRow/style.js create mode 100644 packages/web/src/components/AppConnections/index.jsx create mode 100644 packages/web/src/components/AppFlows/index.jsx create mode 100644 packages/web/src/components/AppIcon/index.jsx create mode 100644 packages/web/src/components/AppRow/index.jsx create mode 100644 packages/web/src/components/AppRow/style.js create mode 100644 packages/web/src/components/Can/index.jsx create mode 100644 packages/web/src/components/CheckoutCompletedAlert/index.ee.jsx create mode 100644 packages/web/src/components/ChooseAppAndEventSubstep/index.jsx create mode 100644 packages/web/src/components/ChooseConnectionSubstep/index.jsx create mode 100644 packages/web/src/components/CodeEditor/index.jsx create mode 100644 packages/web/src/components/CodeEditor/style.js create mode 100644 packages/web/src/components/ColorInput/ColorButton/index.jsx create mode 100644 packages/web/src/components/ColorInput/ColorButton/style.jsx create mode 100644 packages/web/src/components/ColorInput/index.jsx create mode 100644 packages/web/src/components/ConditionalIconButton/index.jsx create mode 100644 packages/web/src/components/ConditionalIconButton/style.js create mode 100644 packages/web/src/components/ConfirmationDialog/index.jsx create mode 100644 packages/web/src/components/Container/index.jsx create mode 100644 packages/web/src/components/ControlledAutocomplete/index.jsx create mode 100644 packages/web/src/components/ControlledCheckbox/index.jsx create mode 100644 packages/web/src/components/ControlledCustomAutocomplete/Controller.jsx create mode 100644 packages/web/src/components/ControlledCustomAutocomplete/CustomOptions.jsx create mode 100644 packages/web/src/components/ControlledCustomAutocomplete/Options.jsx create mode 100644 packages/web/src/components/ControlledCustomAutocomplete/index.jsx create mode 100644 packages/web/src/components/ControlledCustomAutocomplete/style.js create mode 100644 packages/web/src/components/CustomLogo/index.ee.jsx create mode 100644 packages/web/src/components/CustomLogo/style.ee.js create mode 100644 packages/web/src/components/DefaultLogo/index.jsx create mode 100644 packages/web/src/components/DeleteAccountDialog/index.ee.jsx create mode 100644 packages/web/src/components/DeleteRoleButton/index.ee.jsx create mode 100644 packages/web/src/components/DeleteUserButton/index.ee.jsx create mode 100644 packages/web/src/components/Drawer/index.jsx create mode 100644 packages/web/src/components/Drawer/style.js create mode 100644 packages/web/src/components/DynamicField/DynamicFieldEntry.jsx create mode 100644 packages/web/src/components/DynamicField/index.jsx create mode 100644 packages/web/src/components/EditableTypography/index.jsx create mode 100644 packages/web/src/components/EditableTypography/style.js create mode 100644 packages/web/src/components/Editor/index.jsx create mode 100644 packages/web/src/components/EditorLayout/index.jsx create mode 100644 packages/web/src/components/EditorLayout/style.js create mode 100644 packages/web/src/components/EditorNew/Edge/Edge.jsx create mode 100644 packages/web/src/components/EditorNew/EditorNew.jsx create mode 100644 packages/web/src/components/EditorNew/FlowStepNode/FlowStepNode.jsx create mode 100644 packages/web/src/components/EditorNew/FlowStepNode/style.js create mode 100644 packages/web/src/components/EditorNew/InvisibleNode/InvisibleNode.jsx create mode 100644 packages/web/src/components/EditorNew/constants.js create mode 100644 packages/web/src/components/EditorNew/style.js create mode 100644 packages/web/src/components/EditorNew/useAutoLayout.js create mode 100644 packages/web/src/components/EditorNew/useScrollBoundaries.js create mode 100644 packages/web/src/components/EditorNew/utils.js create mode 100644 packages/web/src/components/ExecutionHeader/index.jsx create mode 100644 packages/web/src/components/ExecutionRow/index.jsx create mode 100644 packages/web/src/components/ExecutionRow/style.js create mode 100644 packages/web/src/components/ExecutionStep/index.jsx create mode 100644 packages/web/src/components/ExecutionStep/style.js create mode 100644 packages/web/src/components/FileUploadInput/index.js create mode 100644 packages/web/src/components/FlowAppIcons/index.jsx create mode 100644 packages/web/src/components/FlowContextMenu/index.jsx create mode 100644 packages/web/src/components/FlowRow/index.jsx create mode 100644 packages/web/src/components/FlowRow/style.js create mode 100644 packages/web/src/components/FlowStep/index.jsx create mode 100644 packages/web/src/components/FlowStep/style.js create mode 100644 packages/web/src/components/FlowStepContextMenu/index.jsx create mode 100644 packages/web/src/components/FlowSubstep/FilterConditions/index.jsx create mode 100644 packages/web/src/components/FlowSubstep/index.jsx create mode 100644 packages/web/src/components/FlowSubstepTitle/index.jsx create mode 100644 packages/web/src/components/FlowSubstepTitle/style.jsx create mode 100644 packages/web/src/components/ForgotPasswordForm/index.ee.jsx create mode 100644 packages/web/src/components/Form/index.jsx create mode 100644 packages/web/src/components/HideOnScroll/index.jsx create mode 100644 packages/web/src/components/ImportFlowDialog/index.jsx create mode 100644 packages/web/src/components/InputCreator/index.jsx create mode 100644 packages/web/src/components/InstallationForm/index.jsx create mode 100644 packages/web/src/components/IntermediateStepCount/index.jsx create mode 100644 packages/web/src/components/IntermediateStepCount/style.js create mode 100644 packages/web/src/components/IntlProvider/index.jsx create mode 100644 packages/web/src/components/Invoices/index.ee.jsx create mode 100644 packages/web/src/components/JSONViewer/index.jsx create mode 100644 packages/web/src/components/JSONViewer/style.jsx create mode 100644 packages/web/src/components/Layout/index.jsx create mode 100644 packages/web/src/components/ListItemLink/index.jsx create mode 100644 packages/web/src/components/ListLoader/index.jsx create mode 100644 packages/web/src/components/LoginForm/index.jsx create mode 100644 packages/web/src/components/Logo/index.jsx create mode 100644 packages/web/src/components/Logo/style.js create mode 100644 packages/web/src/components/MationLogo/assets/mation-logo.svg create mode 100644 packages/web/src/components/MationLogo/index.jsx create mode 100644 packages/web/src/components/MetadataProvider/index.jsx create mode 100644 packages/web/src/components/NoResultFound/index.jsx create mode 100644 packages/web/src/components/NoResultFound/style.js create mode 100644 packages/web/src/components/NotFound/index.jsx create mode 100644 packages/web/src/components/NotificationCard/index.jsx create mode 100644 packages/web/src/components/OAuthClientsDialog/index.ee.jsx create mode 100644 packages/web/src/components/PageTitle/index.jsx create mode 100644 packages/web/src/components/PermissionCatalogField/ActionField.jsx create mode 100644 packages/web/src/components/PermissionCatalogField/PermissionCatalogFieldLoader/index.jsx create mode 100644 packages/web/src/components/PermissionCatalogField/PermissionSettings.ee.jsx create mode 100644 packages/web/src/components/PermissionCatalogField/index.ee.jsx create mode 100644 packages/web/src/components/Portal/index.jsx create mode 100644 packages/web/src/components/PowerInput/Popper.jsx create mode 100644 packages/web/src/components/PowerInput/SuggestionItem.jsx create mode 100644 packages/web/src/components/PowerInput/Suggestions.jsx create mode 100644 packages/web/src/components/PowerInput/data.js create mode 100644 packages/web/src/components/PowerInput/index.jsx create mode 100644 packages/web/src/components/PowerInput/style.js create mode 100644 packages/web/src/components/PublicLayout/index.jsx create mode 100644 packages/web/src/components/QueryClientProvider/index.jsx create mode 100644 packages/web/src/components/ResetPasswordForm/index.ee.jsx create mode 100644 packages/web/src/components/RoleList/index.ee.jsx create mode 100644 packages/web/src/components/Router/index.jsx create mode 100644 packages/web/src/components/SearchInput/index.jsx create mode 100644 packages/web/src/components/SearchableJSONViewer/index.jsx create mode 100644 packages/web/src/components/SettingsLayout/index.jsx create mode 100644 packages/web/src/components/SignUpForm/index.ee.jsx create mode 100644 packages/web/src/components/Slate/Element.jsx create mode 100644 packages/web/src/components/Slate/Variable.jsx create mode 100644 packages/web/src/components/Slate/index.jsx create mode 100644 packages/web/src/components/Slate/types.js create mode 100644 packages/web/src/components/Slate/utils.js create mode 100644 packages/web/src/components/SnackbarProvider/index.jsx create mode 100644 packages/web/src/components/SplitButton/index.jsx create mode 100644 packages/web/src/components/SsoProviders/index.ee.jsx create mode 100644 packages/web/src/components/SubscriptionCancelledAlert/index.ee.jsx create mode 100644 packages/web/src/components/Switch/index.jsx create mode 100644 packages/web/src/components/TabPanel/index.jsx create mode 100644 packages/web/src/components/TestSubstep/index.jsx create mode 100644 packages/web/src/components/TextField/index.jsx create mode 100644 packages/web/src/components/ThemeProvider/index.jsx create mode 100644 packages/web/src/components/TrialOverAlert/index.ee.jsx create mode 100644 packages/web/src/components/TrialStatusBadge/index.ee.jsx create mode 100644 packages/web/src/components/TrialStatusBadge/style.ee.jsx create mode 100644 packages/web/src/components/UpgradeFreeTrial/index.ee.jsx create mode 100644 packages/web/src/components/UsageDataInformation/index.ee.jsx create mode 100644 packages/web/src/components/UserList/TablePaginationActions/index.jsx create mode 100644 packages/web/src/components/UserList/index.jsx create mode 100644 packages/web/src/components/UserList/style.js create mode 100644 packages/web/src/components/WebhookUrlInfo/index.jsx create mode 100644 packages/web/src/components/WebhookUrlInfo/style.js create mode 100644 packages/web/src/config/app.js create mode 100644 packages/web/src/config/urls.js create mode 100644 packages/web/src/contexts/Authentication.jsx create mode 100644 packages/web/src/contexts/Editor.jsx create mode 100644 packages/web/src/contexts/FieldEntry.jsx create mode 100644 packages/web/src/contexts/Paddle.ee.jsx create mode 100644 packages/web/src/contexts/StepExecutions.jsx create mode 100644 packages/web/src/helpers/api.js create mode 100644 packages/web/src/helpers/authenticationSteps.js create mode 100644 packages/web/src/helpers/computeAuthStepVariables.js create mode 100644 packages/web/src/helpers/computePermissions.ee.js create mode 100644 packages/web/src/helpers/computeVariables.js create mode 100644 packages/web/src/helpers/copyInputValue.js create mode 100644 packages/web/src/helpers/errors.js create mode 100644 packages/web/src/helpers/filterObject.js create mode 100644 packages/web/src/helpers/isEmpty.js create mode 100644 packages/web/src/helpers/nestObject.js create mode 100644 packages/web/src/helpers/storage.js create mode 100644 packages/web/src/helpers/translationValues.jsx create mode 100644 packages/web/src/helpers/userAbility.js create mode 100644 packages/web/src/hooks/useAcceptInvitation.js create mode 100644 packages/web/src/hooks/useActionSubsteps.js create mode 100644 packages/web/src/hooks/useActions.js create mode 100644 packages/web/src/hooks/useAdminCreateAppConfig.js create mode 100644 packages/web/src/hooks/useAdminCreateOAuthClient.ee.js create mode 100644 packages/web/src/hooks/useAdminCreateRole.js create mode 100644 packages/web/src/hooks/useAdminCreateSamlAuthProvider.js create mode 100644 packages/web/src/hooks/useAdminCreateUser.js create mode 100644 packages/web/src/hooks/useAdminDeleteRole.js create mode 100644 packages/web/src/hooks/useAdminOAuthClient.ee.js create mode 100644 packages/web/src/hooks/useAdminOAuthClients.js create mode 100644 packages/web/src/hooks/useAdminSamlAuthProviderRoleMappings.js create mode 100644 packages/web/src/hooks/useAdminSamlAuthProviders.ee.js create mode 100644 packages/web/src/hooks/useAdminUpdateAppConfig.js create mode 100644 packages/web/src/hooks/useAdminUpdateConfig.js create mode 100644 packages/web/src/hooks/useAdminUpdateOAuthClient.ee.js create mode 100644 packages/web/src/hooks/useAdminUpdateRole.js create mode 100644 packages/web/src/hooks/useAdminUpdateSamlAuthProvider.js create mode 100644 packages/web/src/hooks/useAdminUpdateSamlAuthProviderRoleMappings.js create mode 100644 packages/web/src/hooks/useAdminUpdateUser.js create mode 100644 packages/web/src/hooks/useAdminUser.js create mode 100644 packages/web/src/hooks/useAdminUserDelete.js create mode 100644 packages/web/src/hooks/useAdminUsers.js create mode 100644 packages/web/src/hooks/useApp.js create mode 100644 packages/web/src/hooks/useAppAuth.js create mode 100644 packages/web/src/hooks/useAppConfig.ee.js create mode 100644 packages/web/src/hooks/useAppConnections.js create mode 100644 packages/web/src/hooks/useAppFlows.js create mode 100644 packages/web/src/hooks/useApps.js create mode 100644 packages/web/src/hooks/useAuthenticateApp.ee.js create mode 100644 packages/web/src/hooks/useAuthentication.js create mode 100644 packages/web/src/hooks/useAutomatischConfig.js create mode 100644 packages/web/src/hooks/useAutomatischInfo.js create mode 100644 packages/web/src/hooks/useAutomatischNotifications.js create mode 100644 packages/web/src/hooks/useCloud.js create mode 100644 packages/web/src/hooks/useConnectionFlows.js create mode 100644 packages/web/src/hooks/useCreateAccessToken.js create mode 100644 packages/web/src/hooks/useCreateConnection.js create mode 100644 packages/web/src/hooks/useCreateConnectionAuthUrl.js create mode 100644 packages/web/src/hooks/useCreateFlow.js create mode 100644 packages/web/src/hooks/useCreateStep.js create mode 100644 packages/web/src/hooks/useCurrentUser.js create mode 100644 packages/web/src/hooks/useCurrentUserAbility.js create mode 100644 packages/web/src/hooks/useDeleteConnection.js create mode 100644 packages/web/src/hooks/useDeleteCurrentUser.js create mode 100644 packages/web/src/hooks/useDeleteFlow.js create mode 100644 packages/web/src/hooks/useDeleteStep.js create mode 100644 packages/web/src/hooks/useDocsUrl.js create mode 100644 packages/web/src/hooks/useDownloadJsonAsFile.js create mode 100644 packages/web/src/hooks/useDuplicateFlow.js create mode 100644 packages/web/src/hooks/useDynamicData.js create mode 100644 packages/web/src/hooks/useDynamicFields.js create mode 100644 packages/web/src/hooks/useEnqueueSnackbar.js create mode 100644 packages/web/src/hooks/useExecution.js create mode 100644 packages/web/src/hooks/useExecutionSteps.js create mode 100644 packages/web/src/hooks/useExecutions.js create mode 100644 packages/web/src/hooks/useExportFlow.js create mode 100644 packages/web/src/hooks/useFieldEntryContext.jsx create mode 100644 packages/web/src/hooks/useFlow.js create mode 100644 packages/web/src/hooks/useFlows.js create mode 100644 packages/web/src/hooks/useForgotPassword.js create mode 100644 packages/web/src/hooks/useFormatMessage.js create mode 100644 packages/web/src/hooks/useImportFlow.js create mode 100644 packages/web/src/hooks/useInstallation.js create mode 100644 packages/web/src/hooks/useInvoices.ee.js create mode 100644 packages/web/src/hooks/useLazyApps.js create mode 100644 packages/web/src/hooks/useLicense.js create mode 100644 packages/web/src/hooks/useOAuthClients.js create mode 100644 packages/web/src/hooks/usePaddle.ee.js create mode 100644 packages/web/src/hooks/usePaddleInfo.ee.js create mode 100644 packages/web/src/hooks/usePaymentPlans.ee.js create mode 100644 packages/web/src/hooks/usePermissionCatalog.ee.js create mode 100644 packages/web/src/hooks/usePlanAndUsage.js create mode 100644 packages/web/src/hooks/usePrevious.js create mode 100644 packages/web/src/hooks/useRegisterUser.js create mode 100644 packages/web/src/hooks/useResetConnection.js create mode 100644 packages/web/src/hooks/useResetPassword.js create mode 100644 packages/web/src/hooks/useRevokeAccessToken.js create mode 100644 packages/web/src/hooks/useRole.ee.js create mode 100644 packages/web/src/hooks/useRoles.ee.js create mode 100644 packages/web/src/hooks/useSamlAuthProvider.js create mode 100644 packages/web/src/hooks/useSamlAuthProviders.ee.js create mode 100644 packages/web/src/hooks/useStepConnection.js create mode 100644 packages/web/src/hooks/useStepWithTestExecutions.js create mode 100644 packages/web/src/hooks/useSubscription.ee.js create mode 100644 packages/web/src/hooks/useTestConnection.js create mode 100644 packages/web/src/hooks/useTestStep.js create mode 100644 packages/web/src/hooks/useTriggerSubsteps.js create mode 100644 packages/web/src/hooks/useTriggers.js create mode 100644 packages/web/src/hooks/useUpdateConnection.js create mode 100644 packages/web/src/hooks/useUpdateCurrentUser.js create mode 100644 packages/web/src/hooks/useUpdateCurrentUserPassword.js create mode 100644 packages/web/src/hooks/useUpdateFlow.js create mode 100644 packages/web/src/hooks/useUpdateFlowStatus.js create mode 100644 packages/web/src/hooks/useUpdateStep.js create mode 100644 packages/web/src/hooks/useUserApps.js create mode 100644 packages/web/src/hooks/useUserTrial.ee.js create mode 100644 packages/web/src/hooks/useVerifyConnection.js create mode 100644 packages/web/src/hooks/useVersion.js create mode 100644 packages/web/src/index.jsx create mode 100644 packages/web/src/locales/en.json create mode 100644 packages/web/src/pages/AcceptInvitation/index.jsx create mode 100644 packages/web/src/pages/AdminApplication/index.jsx create mode 100644 packages/web/src/pages/AdminApplications/index.jsx create mode 100644 packages/web/src/pages/Application/index.jsx create mode 100644 packages/web/src/pages/Applications/index.jsx create mode 100644 packages/web/src/pages/Authentication/RoleMappings.jsx create mode 100644 packages/web/src/pages/Authentication/RoleMappingsFieldsArray.jsx create mode 100644 packages/web/src/pages/Authentication/SamlConfiguration.jsx create mode 100644 packages/web/src/pages/Authentication/index.jsx create mode 100644 packages/web/src/pages/BillingAndUsageSettings/index.ee.jsx create mode 100644 packages/web/src/pages/CreateRole/index.ee.jsx create mode 100644 packages/web/src/pages/CreateUser/index.jsx create mode 100644 packages/web/src/pages/Dashboard/index.jsx create mode 100644 packages/web/src/pages/EditRole/index.ee.jsx create mode 100644 packages/web/src/pages/EditUser/index.jsx create mode 100644 packages/web/src/pages/Editor/create.jsx create mode 100644 packages/web/src/pages/Editor/index.jsx create mode 100644 packages/web/src/pages/Editor/routes.jsx create mode 100644 packages/web/src/pages/Execution/index.jsx create mode 100644 packages/web/src/pages/Executions/index.jsx create mode 100644 packages/web/src/pages/Flow/index.jsx create mode 100644 packages/web/src/pages/Flows/index.jsx create mode 100644 packages/web/src/pages/ForgotPassword/index.ee.jsx create mode 100644 packages/web/src/pages/Installation/index.jsx create mode 100644 packages/web/src/pages/Login/index.jsx create mode 100644 packages/web/src/pages/LoginCallback/index.jsx create mode 100644 packages/web/src/pages/Notifications/index.jsx create mode 100644 packages/web/src/pages/PlanUpgrade/index.ee.jsx create mode 100644 packages/web/src/pages/ProfileSettings/index.jsx create mode 100644 packages/web/src/pages/ResetPassword/index.ee.jsx create mode 100644 packages/web/src/pages/Roles/index.ee.jsx create mode 100644 packages/web/src/pages/SignUp/index.ee.jsx create mode 100644 packages/web/src/pages/UserInterface/index.jsx create mode 100644 packages/web/src/pages/Users/index.jsx create mode 100644 packages/web/src/propTypes/propTypes.js create mode 100644 packages/web/src/reportWebVitals.js create mode 100644 packages/web/src/routes.jsx create mode 100644 packages/web/src/settingsRoutes.jsx create mode 100644 packages/web/src/setupTests.js create mode 100644 packages/web/src/styles/theme.js create mode 100644 packages/web/yarn.lock create mode 100644 render.yaml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..8886e95 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu-22.04 diff --git a/.devcontainer/boot.sh b/.devcontainer/boot.sh new file mode 100644 index 0000000..15efc5f --- /dev/null +++ b/.devcontainer/boot.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +CURRENT_DIR="$(pwd)" +BACKEND_PORT=3000 +WEB_PORT=3001 + +echo "Configuring backend environment variables..." + +cd packages/backend + +rm -rf .env + +echo " +PORT=$BACKEND_PORT +WEB_APP_URL=http://localhost:$WEB_PORT +APP_ENV=development +POSTGRES_DATABASE=automatisch +POSTGRES_PORT=5432 +POSTGRES_HOST=postgres +POSTGRES_USERNAME=automatisch_user +POSTGRES_PASSWORD=automatisch_password +ENCRYPTION_KEY=sample_encryption_key +WEBHOOK_SECRET_KEY=sample_webhook_secret_key +APP_SECRET_KEY=sample_app_secret_key +REDIS_HOST=redis +SERVE_WEB_APP_SEPARATELY=true" >> .env + +echo "Installing backend dependencies..." + +yarn + +cd $CURRENT_DIR + +echo "Configuring web environment variables..." + +cd packages/web + +rm -rf .env + +echo " +PORT=$WEB_PORT +REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT +" >> .env + +echo "Installing web dependencies..." + +yarn + +cd $CURRENT_DIR + +echo "Migrating database..." + +cd packages/backend + +yarn db:migrate +yarn db:seed:user + +echo "Done!" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..19273bb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,53 @@ +{ + "name": "Automatisch", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "features": { + "ghcr.io/devcontainers/features/git:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": 18 + }, + "ghcr.io/devcontainers/features/common-utils:1": { + "username": "vscode", + "uid": 1000, + "gid": 1000, + "installZsh": true, + "installOhMyZsh": true, + "configureZshAsDefaultShell": true, + "upgradePackages": true + } + }, + + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + + "portsAttributes": { + "3000": { + "label": "Backend", + "onAutoForward": "silent", + "protocol": "http" + }, + "3001": { + "label": "Frontend", + "onAutoForward": "silent", + "protocol": "http" + } + }, + + "forwardPorts": [3000, 3001], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": ["bash", ".devcontainer/boot.sh"] + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..580c9f5 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,63 @@ +version: '3.9' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + volumes: + - ..:/workspace:cached + command: sleep infinity + postgres: + image: 'postgres:14.5-alpine' + environment: + - POSTGRES_DB=automatisch + - POSTGRES_USER=automatisch_user + - POSTGRES_PASSWORD=automatisch_password + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'] + interval: 10s + timeout: 5s + retries: 5 + ports: + - '5432:5432' + expose: + - 5432 + redis: + image: 'redis:7.0.4-alpine' + volumes: + - redis_data:/data + ports: + - '6379:6379' + expose: + - 6379 + keycloak: + image: quay.io/keycloak/keycloak:21.1 + restart: always + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - KC_DB=postgres + - KC_DB_URL_HOST=postgres + - KC_DB_URL_DATABASE=keycloak + - KC_DB_USERNAME=automatisch_user + - KC_DB_PASSWORD=automatisch_password + - KC_HEALTH_ENABLED=true + ports: + - "8080:8080" + command: start-dev + depends_on: + - postgres + healthcheck: + test: "curl -f http://localhost:8080/health/ready || exit 1" + volumes: + - keycloak:/opt/keycloak/data/ + expose: + - 8080 + +volumes: + postgres_data: + redis_data: + keycloak: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc455d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +**/node_modules/ +**/dist/ +**/logs/ +**/.devcontainer +**/.github +**/.vscode +**/.env +**/.env.test +**/.env.production +**/yarn-error.log +packages/docs +packages/e2e-test diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..54c3773 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,51 @@ +name: Automatisch Backend Tests +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + test: + timeout-minutes: 60 + runs-on: + - ubuntu-latest + services: + postgres: + image: postgres:14.5-alpine + env: + POSTGRES_DB: automatisch_test + POSTGRES_USER: automatisch_test_user + POSTGRES_PASSWORD: automatisch_test_user_password + options: >- + --health-cmd "pg_isready -U automatisch_test_user -d automatisch_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis:7.0.4-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: yarn + working-directory: packages/backend + - name: Copy .env-example.test file to .env.test + run: cp .env-example.test .env.test + working-directory: packages/backend + - name: Run tests + run: yarn test:coverage + working-directory: packages/backend diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a75b0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: Automatisch CI +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + linter: + runs-on: ubuntu-latest + steps: + - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." + - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" + - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '18' + cache: 'yarn' + cache-dependency-path: packages/backend/yarn.lock + - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." + - run: echo "🖥️ The workflow is now ready to test your code on the runner." + - run: yarn --frozen-lockfile + working-directory: packages/backend + - run: yarn lint + working-directory: packages/backend + - run: echo "🍏 This job's status is ${{ job.status }}." + start-backend-server: + runs-on: ubuntu-latest + steps: + - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." + - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" + - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '18' + cache: 'yarn' + cache-dependency-path: packages/backend/yarn.lock + - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." + - run: echo "🖥️ The workflow is now ready to test your code on the runner." + - run: yarn --frozen-lockfile + working-directory: packages/backend + - run: yarn start + working-directory: packages/backend + env: + ENCRYPTION_KEY: sample_encryption_key + WEBHOOK_SECRET_KEY: sample_webhook_secret_key + - run: echo "🍏 This job's status is ${{ job.status }}." + start-backend-worker: + runs-on: ubuntu-latest + steps: + - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." + - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" + - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '18' + cache: 'yarn' + cache-dependency-path: packages/backend/yarn.lock + - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." + - run: echo "🖥️ The workflow is now ready to test your code on the runner." + - run: yarn --frozen-lockfile + working-directory: packages/backend + - run: yarn start:worker + working-directory: packages/backend + env: + ENCRYPTION_KEY: sample_encryption_key + WEBHOOK_SECRET_KEY: sample_webhook_secret_key + - run: echo "🍏 This job's status is ${{ job.status }}." + build-web: + runs-on: ubuntu-latest + steps: + - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." + - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" + - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '18' + cache: 'yarn' + cache-dependency-path: packages/web/yarn.lock + - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." + - run: echo "🖥️ The workflow is now ready to test your code on the runner." + - run: yarn --frozen-lockfile + working-directory: packages/web + - run: yarn build + working-directory: packages/web + env: + CI: false + - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/.github/workflows/docs-change.yml b/.github/workflows/docs-change.yml new file mode 100644 index 0000000..b95b80f --- /dev/null +++ b/.github/workflows/docs-change.yml @@ -0,0 +1,32 @@ +name: Automatisch Docs Change +on: + pull_request: + paths: + - 'packages/docs/**' +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Label PR + uses: actions/github-script@v6 + with: + script: | + const { pull_request } = context.payload; + + const label = 'documentation-change'; + const hasLabel = pull_request.labels.some(({ name }) => name === label); + + if (!hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pull_request.number, + labels: [label], + }); + + console.log(`Label "${label}" added to PR #${pull_request.number}`); + } else { + console.log(`Label "${label}" already exists on PR #${pull_request.number}`); + } diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..3928fd3 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,145 @@ +name: Automatisch UI Tests +on: + push: + branches: + - main + # pull_request: + # paths: + # - 'packages/backend/**' + # - 'packages/e2e-tests/**' + # - 'packages/web/**' + # - '!packages/backend/src/apps/**' + workflow_dispatch: + +env: + ENCRYPTION_KEY: sample_encryption_key + WEBHOOK_SECRET_KEY: sample_webhook_secret_key + APP_SECRET_KEY: sample_app_secret_key + POSTGRES_HOST: localhost + POSTGRES_DATABASE: automatisch + POSTGRES_PORT: 5432 + POSTGRES_USERNAME: automatisch_user + POSTGRES_PASSWORD: automatisch_password + REDIS_HOST: localhost + APP_ENV: production + LICENSE_KEY: dummy_license_key + +jobs: + test: + timeout-minutes: 60 + runs-on: + - ubuntu-latest + services: + postgres: + image: postgres:14.5-alpine + env: + POSTGRES_DB: automatisch + POSTGRES_USER: automatisch_user + POSTGRES_PASSWORD: automatisch_password + options: >- + --health-cmd "pg_isready -U automatisch_user -d automatisch" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis:7.0.4-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + cache-dependency-path: | + packages/backend/yarn.lock + packages/web/yarn.lock + packages/e2e-tests/yarn.lock + - name: Install backend dependencies + run: yarn --frozen-lockfile + working-directory: ./packages/backend + - name: Install web dependencies + run: yarn --frozen-lockfile + working-directory: ./packages/web + - name: Install e2e-tests dependencies + run: yarn --frozen-lockfile + working-directory: ./packages/e2e-tests + - name: Get installed Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV + working-directory: ./packages/e2e-tests + - name: Cache playwright binaries + uses: actions/cache@v3 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + working-directory: ./packages/e2e-tests + if: steps.playwright-cache.outputs.cache-hit != 'true' + - name: Build Automatisch web + run: yarn build + env: + # Keep this until we clean up warnings in build processes + CI: false + working-directory: ./packages/web + - name: Migrate database + working-directory: ./packages/backend + run: yarn db:migrate + - name: Install certutils + run: sudo apt install -y libnss3-tools + - name: Install mkcert + run: | + curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64" \ + && chmod +x mkcert-v*-linux-amd64 \ + && sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert + - name: Install root certificate via mkcert + run: mkcert -install + - name: Create certificate + run: mkcert automatisch.io "*.automatisch.io" localhost 127.0.0.1 ::1 + working-directory: ./packages/e2e-tests + - name: Set CAROOT environment variable + run: echo "NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem" >> "$GITHUB_ENV" + - name: Override license server with local server + run: sudo echo "127.0.0.1 license.automatisch.io" | sudo tee -a /etc/hosts + - name: Run local license server + working-directory: ./packages/e2e-tests + run: sudo yarn start-mock-license-server & + - name: Run Automatisch + run: yarn start & + working-directory: ./packages/backend + - name: Run Automatisch worker + run: yarn start:worker & + working-directory: ./packages/backend + - name: Setup upterm session + if: false + uses: lhotari/action-upterm@v1 + with: + limit-access-to-actor: true + limit-access-to-users: barinali + - name: Run Playwright tests + working-directory: ./packages/e2e-tests + env: + LOGIN_EMAIL: user@automatisch.io + LOGIN_PASSWORD: sample + BACKEND_APP_URL: http://localhost:3000 + BASE_URL: http://localhost:3000 + GITHUB_CLIENT_ID: 1c0417daf898adfbd99a + GITHUB_CLIENT_SECRET: 3328fa814dd582ccd03dbe785cfd683fb8da92b3 + run: yarn test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: packages/e2e-tests/test-results + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..186721f --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# cypress environment variables file +cypress.env.json + +# cypress screenshots +packages/e2e-tests/cypress/screenshots + +# cypress videos +packages/e2e-tests/cypress/videos/ + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# MacOS finder preferences +.DS_store diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a9d0873 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +18.19.0 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a9d0873 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18.19.0 diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..e340799 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + singleQuote: true, +}; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0619573 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch server-side", + "type": "node-terminal", + "request": "launch", + "cwd": "${workspaceFolder}/packages/backend", + "command": "yarn dev" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f62af99 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000..568d245 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +network-timeout 400000 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bdbcb85 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +Demonstrating empathy and kindness toward other people +Being respectful of differing opinions, viewpoints, and experiences +Giving and gracefully accepting constructive feedback +Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +Focusing on what is best not just for us as individuals, but for the overall community +Examples of unacceptable behavior include: + +The use of sexualized language or imagery, and sexual attention or advances of any kind +Trolling, insulting or derogatory comments, and personal or political attacks +Public or private harassment +Publishing others’ private information, such as a physical or email address, without their explicit permission +Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ali@automatisch.io. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +Community Impact: A violation through a single incident or series of actions. + +Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +Community Impact: A serious violation of community standards, including sustained inappropriate behavior. + +Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +Consequence: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by Mozilla’s code of conduct enforcement ladder. + +For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT.md b/CONTRIBUTOR_LICENSE_AGREEMENT.md new file mode 100644 index 0000000..423eb41 --- /dev/null +++ b/CONTRIBUTOR_LICENSE_AGREEMENT.md @@ -0,0 +1,5 @@ +# Automatisch Contributor License Agreement + +I give Automatisch permission to license my contributions on any terms they like. I am giving them this license in order to make it possible for them to accept my contributions into their project. + +**_As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim._** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..daa2e5d --- /dev/null +++ b/LICENSE @@ -0,0 +1,3 @@ +LICENSE.agpl (AGPL-3.0) applies to all files in this +repository, except for files that contain ".ee." in their name +which are covered by LICENSE.enterprise. diff --git a/LICENSE.agpl b/LICENSE.agpl new file mode 100644 index 0000000..162676c --- /dev/null +++ b/LICENSE.agpl @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSE.enterprise b/LICENSE.enterprise new file mode 100644 index 0000000..a76c7a5 --- /dev/null +++ b/LICENSE.enterprise @@ -0,0 +1,35 @@ +The Automatisch Enterprise license (the “Enterprise License”) +Copyright (c) 2023-present AB Software GmbH. + +With regard to the Automatisch Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have a valid +Automatisch Enterprise license for the correct number of user seats. Subject +to the foregoing sentence, you are free to modify this Software and publish +patches to the Software. You agree that Automatisch and/or its licensors +(as applicable) retain all right, title and interest in and to all such +modifications and/or patches, and all such modifications and/or patches may +only be used, copied, modified, displayed, distributed, or otherwise exploited +with a valid Automatisch Enterprise license for the correct number of user seats. +Notwithstanding the foregoing, you may copy and modify the Software for +development and testing purposes, without requiring a subscription. You agree +that Automatisch and/or its licensors (as applicable) retain all right, title +and interest in and to all such modifications. You are not granted any other +rights beyond what is expressly stated herein. Subject to the foregoing, it is +forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software. + +The full text of this Enterprise License 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. + +For all third party components incorporated into the Automatisch Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07f4523 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,70 @@ +version: '3.9' +services: + main: + build: + context: ./docker + dockerfile: Dockerfile.compose + entrypoint: /compose-entrypoint.sh + ports: + - '${PORT}:${PORT}' + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + environment: + - HOST=${HOST} + - PROTOCOL=${PROTOCOL} + - PORT=${PORT} + - APP_ENV=${APP_ENV} + - REDIS_HOST=${REDIS_HOST} + - POSTGRES_HOST=${POSTGRES_HOST} + - POSTGRES_DATABASE=${POSTGRES_DATABASE} + - POSTGRES_USERNAME=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - ENCRYPTION_KEY + - WEBHOOK_SECRET_KEY + - APP_SECRET_KEY + volumes: + - automatisch_storage:/automatisch/storage + worker: + build: + context: ./docker + dockerfile: Dockerfile.compose + entrypoint: /compose-entrypoint.sh + depends_on: + - main + environment: + - APP_ENV=${APP_ENV} + - REDIS_HOST=${REDIS_HOST} + - POSTGRES_HOST=${POSTGRES_HOST} + - POSTGRES_DATABASE=${POSTGRES_DATABASE} + - POSTGRES_USERNAME=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - ENCRYPTION_KEY + - WEBHOOK_SECRET_KEY + - APP_SECRET_KEY + - WORKER=true + volumes: + - automatisch_storage:/automatisch/storage + postgres: + image: 'postgres:14.5' + environment: + - POSTGRES_DB=${POSTGRES_DATABASE} + - POSTGRES_USER=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'] + interval: 10s + timeout: 5s + retries: 5 + redis: + image: 'redis:7.0.4' + volumes: + - redis_data:/data +volumes: + automatisch_storage: + postgres_data: + redis_data: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..84789e2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 +FROM node:18-alpine + +ENV PORT=3000 + +RUN \ + apk --no-cache add --virtual build-dependencies python3 build-base git make g++ + +WORKDIR /automatisch + +# copy the app, note .dockerignore +COPY . /automatisch + +RUN cd packages/web && yarn + +RUN cd packages/web && yarn build + +RUN cd packages/backend && yarn --production + +RUN \ + rm -rf /usr/local/share/.cache/ && \ + apk del build-dependencies + +COPY ./docker/entrypoint.sh /entrypoint.sh + +EXPOSE 3000 +ENTRYPOINT ["sh", "/entrypoint.sh"] diff --git a/docker/Dockerfile.compose b/docker/Dockerfile.compose new file mode 100644 index 0000000..042596f --- /dev/null +++ b/docker/Dockerfile.compose @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 +FROM automatischio/automatisch:latest +WORKDIR /automatisch + +RUN apk add --no-cache openssl dos2unix + +COPY ./compose-entrypoint.sh /compose-entrypoint.sh +RUN dos2unix /compose-entrypoint.sh + +EXPOSE 3000 +ENTRYPOINT ["sh", "/compose-entrypoint.sh"] diff --git a/docker/compose-entrypoint.sh b/docker/compose-entrypoint.sh new file mode 100755 index 0000000..c02ae98 --- /dev/null +++ b/docker/compose-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +set -e + +if [ ! -f /automatisch/storage/.env ]; then + >&2 echo "Saving environment variables" + ENCRYPTION_KEY="${ENCRYPTION_KEY:-$(openssl rand -base64 36)}" + WEBHOOK_SECRET_KEY="${WEBHOOK_SECRET_KEY:-$(openssl rand -base64 36)}" + APP_SECRET_KEY="${APP_SECRET_KEY:-$(openssl rand -base64 36)}" + echo "ENCRYPTION_KEY=$ENCRYPTION_KEY" >> /automatisch/storage/.env + echo "WEBHOOK_SECRET_KEY=$WEBHOOK_SECRET_KEY" >> /automatisch/storage/.env + echo "APP_SECRET_KEY=$APP_SECRET_KEY" >> /automatisch/storage/.env +fi + +# initiate env. vars. from /automatisch/storage/.env file +export $(grep -v '^#' /automatisch/storage/.env | xargs) + +# migration for webhook secret key, will be removed in the future. +if [[ -z "${WEBHOOK_SECRET_KEY}" ]]; then + WEBHOOK_SECRET_KEY="$(openssl rand -base64 36)" + echo "WEBHOOK_SECRET_KEY=$WEBHOOK_SECRET_KEY" >> /automatisch/storage/.env +fi + +echo "Environment variables have been set!" + +sh /entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..322a468 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +set -e + +cd packages/backend + +if [ -n "$WORKER" ]; then + yarn start:worker +else + yarn db:migrate + yarn db:seed:user + yarn start +fi diff --git a/packages/backend/.env-example b/packages/backend/.env-example new file mode 100644 index 0000000..27ee434 --- /dev/null +++ b/packages/backend/.env-example @@ -0,0 +1,22 @@ +HOST=localhost +PROTOCOL=http +PORT=3000 +WEB_APP_URL=http://localhost:3001 +WEBHOOK_URL=http://localhost:3000 +APP_ENV=development +POSTGRES_DATABASE=automatisch_development +POSTGRES_PORT=5432 +POSTGRES_HOST=localhost +POSTGRES_USERNAME=automatish_development_user +POSTGRES_PASSWORD= +POSTGRES_ENABLE_SSL=false +ENCRYPTION_KEY=sample-encryption-key +WEBHOOK_SECRET_KEY=sample-webhook-key +APP_SECRET_KEY=sample-app-secret-key +REDIS_PORT=6379 +REDIS_HOST=127.0.0.1 +REDIS_USERNAME=redis_username +REDIS_PASSWORD=redis_password +REDIS_TLS=true +ENABLE_BULLMQ_DASHBOARD=false +SERVE_WEB_APP_SEPARATELY=true diff --git a/packages/backend/.env-example.test b/packages/backend/.env-example.test new file mode 100644 index 0000000..9e435c0 --- /dev/null +++ b/packages/backend/.env-example.test @@ -0,0 +1,15 @@ +APP_ENV=test +HOST=localhost +PROTOCOL=http +PORT=3000 +LOG_LEVEL=debug +ENCRYPTION_KEY=sample_encryption_key +WEBHOOK_SECRET_KEY=sample_webhook_secret_key +APP_SECRET_KEY=sample_app_secret_key +POSTGRES_HOST=localhost +POSTGRES_DATABASE=automatisch_test +POSTGRES_PORT=5432 +POSTGRES_USERNAME=automatisch_test_user +POSTGRES_PASSWORD=automatisch_test_user_password +REDIS_HOST=localhost +AUTOMATISCH_CLOUD=true diff --git a/packages/backend/.eslintignore b/packages/backend/.eslintignore new file mode 100644 index 0000000..11c7540 --- /dev/null +++ b/packages/backend/.eslintignore @@ -0,0 +1,14 @@ +node_modules +dist +build +coverage +packages/docs/* +packages/e2e-tests + +.eslintrc.js +husky.config.js +jest.config.js +jest.config.base.js +lint-staged.config.js + +webpack.config.js diff --git a/packages/backend/.eslintrc.json b/packages/backend/.eslintrc.json new file mode 100644 index 0000000..3731ac7 --- /dev/null +++ b/packages/backend/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "root": true, + "env": { + "node": true, + "es6": true + }, + "extends": ["eslint:recommended", "prettier"], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + } +} diff --git a/packages/backend/README.md b/packages/backend/README.md new file mode 100644 index 0000000..680345f --- /dev/null +++ b/packages/backend/README.md @@ -0,0 +1,4 @@ +# `backend` + +The open source Zapier alternative. Build workflow automation without spending +time and money. diff --git a/packages/backend/bin/database/client.js b/packages/backend/bin/database/client.js new file mode 100644 index 0000000..08681b9 --- /dev/null +++ b/packages/backend/bin/database/client.js @@ -0,0 +1,9 @@ +import pg from 'pg'; + +const client = new pg.Client({ + host: 'localhost', + user: 'postgres', + port: 5432, +}); + +export default client; diff --git a/packages/backend/bin/database/convert-migrations.js b/packages/backend/bin/database/convert-migrations.js new file mode 100644 index 0000000..8334edc --- /dev/null +++ b/packages/backend/bin/database/convert-migrations.js @@ -0,0 +1,31 @@ +import appConfig from '../../src/config/app.js'; +import logger from '../../src/helpers/logger.js'; +import '../../src/config/orm.js'; +import { client as knex } from '../../src/config/database.js'; + +export const renameMigrationsAsJsFiles = async () => { + if (!appConfig.isDev) { + return; + } + + try { + const tableExists = await knex.schema.hasTable('knex_migrations'); + + if (tableExists) { + await knex('knex_migrations') + .where('name', 'like', '%.ts') + .update({ + name: knex.raw("REPLACE(name, '.ts', '.js')"), + }); + logger.info( + `Migration file names with typescript renamed as JS file names!` + ); + } + } catch (err) { + logger.error(err.message); + } + + await knex.destroy(); +}; + +renameMigrationsAsJsFiles(); diff --git a/packages/backend/bin/database/create.js b/packages/backend/bin/database/create.js new file mode 100644 index 0000000..572db5f --- /dev/null +++ b/packages/backend/bin/database/create.js @@ -0,0 +1,3 @@ +import { createDatabaseAndUser } from './utils.js'; + +createDatabaseAndUser(); diff --git a/packages/backend/bin/database/drop.js b/packages/backend/bin/database/drop.js new file mode 100644 index 0000000..15b9702 --- /dev/null +++ b/packages/backend/bin/database/drop.js @@ -0,0 +1,3 @@ +import { dropDatabase } from './utils.js'; + +dropDatabase(); diff --git a/packages/backend/bin/database/seed-user.js b/packages/backend/bin/database/seed-user.js new file mode 100644 index 0000000..25f9bf7 --- /dev/null +++ b/packages/backend/bin/database/seed-user.js @@ -0,0 +1,3 @@ +import { createUser } from './utils.js'; + +createUser(); diff --git a/packages/backend/bin/database/utils.js b/packages/backend/bin/database/utils.js new file mode 100644 index 0000000..5b1ca16 --- /dev/null +++ b/packages/backend/bin/database/utils.js @@ -0,0 +1,145 @@ +import appConfig from '../../src/config/app.js'; +import logger from '../../src/helpers/logger.js'; +import client from './client.js'; +import User from '../../src/models/user.js'; +import Config from '../../src/models/config.js'; +import Role from '../../src/models/role.js'; +import '../../src/config/orm.js'; +import process from 'process'; + +async function fetchAdminRole() { + const role = await Role.query() + .where({ + name: 'Admin', + }) + .limit(1) + .first(); + + return role; +} + +export async function createUser( + email = 'user@automatisch.io', + password = 'sample' +) { + if (appConfig.disableSeedUser) { + logger.info('Seed user is disabled.'); + + process.exit(0); + + return; + } + + const UNIQUE_VIOLATION_CODE = '23505'; + + const role = await fetchAdminRole(); + const userParams = { + email, + password, + fullName: 'Initial admin', + roleId: role.id, + }; + + try { + const userCount = await User.query().resultSize(); + + if (userCount === 0) { + const user = await User.query().insertAndFetch(userParams); + logger.info(`User has been saved: ${user.email}`); + + await Config.markInstallationCompleted(); + } else { + logger.info('No need to seed a user.'); + } + } catch (err) { + if (err.nativeError.code !== UNIQUE_VIOLATION_CODE) { + throw err; + } + + logger.info(`User already exists: ${email}`); + } + + process.exit(0); +} + +export const createDatabaseAndUser = async ( + database = appConfig.postgresDatabase, + user = appConfig.postgresUsername +) => { + await client.connect(); + await createDatabase(database); + await createDatabaseUser(user); + await grantPrivileges(database, user); + + await client.end(); + process.exit(0); +}; + +export const createDatabase = async (database = appConfig.postgresDatabase) => { + const DUPLICATE_DB_CODE = '42P04'; + + try { + await client.query(`CREATE DATABASE ${database}`); + logger.info(`Database: ${database} created!`); + } catch (err) { + if (err.code !== DUPLICATE_DB_CODE) { + throw err; + } + + logger.info(`Database: ${database} already exists!`); + } +}; + +export const createDatabaseUser = async (user = appConfig.postgresUsername) => { + const DUPLICATE_OBJECT_CODE = '42710'; + + try { + const result = await client.query(`CREATE USER ${user}`); + logger.info(`Database User: ${user} created!`); + + return result; + } catch (err) { + if (err.code !== DUPLICATE_OBJECT_CODE) { + throw err; + } + + logger.info(`Database User: ${user} already exists!`); + } +}; + +export const grantPrivileges = async ( + database = appConfig.postgresDatabase, + user = appConfig.postgresUsername +) => { + await client.query( + `GRANT ALL PRIVILEGES ON DATABASE ${database} TO ${user};` + ); + + logger.info(`${user} has granted all privileges on ${database}!`); +}; + +export const dropDatabase = async () => { + if (appConfig.appEnv != 'development' && appConfig.appEnv != 'test') { + const errorMessage = + 'Drop database command can be used only with development or test environments!'; + + logger.error(errorMessage); + return; + } + + await client.connect(); + await dropDatabaseAndUser(); + + await client.end(); +}; + +export const dropDatabaseAndUser = async ( + database = appConfig.postgresDatabase, + user = appConfig.postgresUsername +) => { + await client.query(`DROP DATABASE IF EXISTS ${database}`); + logger.info(`Database: ${database} removed!`); + + await client.query(`DROP USER IF EXISTS ${user}`); + logger.info(`Database User: ${user} removed!`); +}; diff --git a/packages/backend/knexfile.js b/packages/backend/knexfile.js new file mode 100644 index 0000000..e38e94d --- /dev/null +++ b/packages/backend/knexfile.js @@ -0,0 +1,33 @@ +import { knexSnakeCaseMappers } from 'objection'; +import appConfig from './src/config/app.js'; +import path, { join } from 'path'; +import { fileURLToPath } from 'url'; + +const fileExtension = 'js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const knexConfig = { + client: 'pg', + connection: { + host: appConfig.postgresHost, + port: appConfig.postgresPort, + user: appConfig.postgresUsername, + password: appConfig.postgresPassword, + database: appConfig.postgresDatabase, + ssl: appConfig.postgresEnableSsl, + }, + asyncStackTraces: appConfig.isDev, + searchPath: [appConfig.postgresSchema], + pool: { min: 0, max: 20 }, + migrations: { + directory: join(__dirname, '/src/db/migrations'), + extension: fileExtension, + loadExtensions: [`.${fileExtension}`], + }, + seeds: { + directory: join(__dirname, '/src/db/seeds'), + }, + ...(appConfig.isTest ? knexSnakeCaseMappers() : {}), +}; + +export default knexConfig; diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..2686d59 --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,116 @@ +{ + "name": "@automatisch/backend", + "version": "0.10.0", + "license": "See LICENSE file", + "description": "The open source Zapier alternative. Build workflow automation without spending time and money.", + "type": "module", + "scripts": { + "dev": "nodemon --exec node src/server.js", + "worker": "nodemon --exec node src/worker.js", + "start": "node src/server.js", + "start:worker": "node src/worker.js", + "pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js", + "test": "APP_ENV=test vitest run", + "test:watch": "APP_ENV=test vitest watch", + "test:coverage": "yarn test --coverage", + "lint": "eslint .", + "db:create": "node ./bin/database/create.js", + "db:seed:user": "node ./bin/database/seed-user.js", + "db:drop": "node ./bin/database/drop.js", + "db:migration:create": "knex migrate:make", + "db:rollback": "knex migrate:rollback", + "db:migrate": "node ./bin/database/convert-migrations.js && knex migrate:latest" + }, + "dependencies": { + "@bull-board/express": "^3.10.1", + "@casl/ability": "^6.5.0", + "@faker-js/faker": "^9.2.0", + "@node-saml/passport-saml": "^4.0.4", + "@rudderstack/rudder-sdk-node": "^1.1.2", + "@sentry/node": "^7.42.0", + "@sentry/tracing": "^7.42.0", + "accounting": "^0.4.1", + "ajv-formats": "^2.1.1", + "axios": "1.6.0", + "bcrypt": "^5.1.0", + "bullmq": "^3.0.0", + "cors": "^2.8.5", + "crypto-js": "^4.1.1", + "debug": "~2.6.9", + "dotenv": "^10.0.0", + "eslint": "^8.13.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "express": "~4.18.2", + "express-async-errors": "^3.1.1", + "express-basic-auth": "^1.2.1", + "fast-xml-parser": "^4.0.11", + "handlebars": "^4.7.7", + "http-errors": "~1.6.3", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "isolated-vm": "^5.0.1", + "jsonwebtoken": "^9.0.0", + "knex": "^2.4.0", + "libphonenumber-js": "^1.10.48", + "lodash.get": "^4.4.2", + "luxon": "2.5.2", + "memory-cache": "^0.2.0", + "morgan": "^1.10.0", + "multer": "1.4.5-lts.1", + "node-html-markdown": "^1.3.0", + "nodemailer": "6.7.0", + "oauth-1.0a": "^2.2.6", + "objection": "^3.0.0", + "passport": "^0.6.0", + "pg": "^8.7.1", + "php-serialize": "^4.0.2", + "pluralize": "^8.0.0", + "prettier": "^2.5.1", + "raw-body": "^2.5.2", + "showdown": "^2.1.0", + "uuid": "^9.0.1", + "winston": "^3.7.1", + "xmlrpc": "^1.3.2" + }, + "contributors": [ + { + "name": "automatisch contributors", + "url": "https://github.com/automatisch/automatisch/graphs/contributors" + } + ], + "homepage": "https://github.com/automatisch/automatisch#readme", + "main": "src/server", + "directories": { + "bin": "bin", + "src": "src", + "test": "__tests__" + }, + "files": [ + "bin", + "src" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/automatisch/automatisch.git" + }, + "bugs": { + "url": "https://github.com/automatisch/automatisch/issues" + }, + "devDependencies": { + "@vitest/coverage-v8": "^2.1.5", + "node-gyp": "^10.1.0", + "nodemon": "^2.0.13", + "supertest": "^6.3.3", + "vitest": "^2.1.5" + }, + "publishConfig": { + "access": "public" + }, + "nodemonConfig": { + "watch": [ + "src/" + ], + "ext": "js" + } +} diff --git a/packages/backend/src/app.js b/packages/backend/src/app.js new file mode 100644 index 0000000..9d1eadf --- /dev/null +++ b/packages/backend/src/app.js @@ -0,0 +1,71 @@ +import createError from 'http-errors'; +import express from 'express'; +import 'express-async-errors'; +import cors from 'cors'; + +import appConfig from './config/app.js'; +import corsOptions from './config/cors-options.js'; +import morgan from './helpers/morgan.js'; +import * as Sentry from './helpers/sentry.ee.js'; +import appAssetsHandler from './helpers/app-assets-handler.js'; +import webUIHandler from './helpers/web-ui-handler.js'; +import errorHandler from './helpers/error-handler.js'; +import './config/orm.js'; +import { + createBullBoardHandler, + serverAdapter, +} from './helpers/create-bull-board-handler.js'; +import injectBullBoardHandler from './helpers/inject-bull-board-handler.js'; +import router from './routes/index.js'; +import configurePassport from './helpers/passport.js'; + +createBullBoardHandler(serverAdapter); + +const app = express(); + +Sentry.init(app); + +Sentry.attachRequestHandler(app); +Sentry.attachTracingHandler(app); + +injectBullBoardHandler(app, serverAdapter); + +appAssetsHandler(app); + +app.use(morgan); + +app.use( + express.json({ + limit: appConfig.requestBodySizeLimit, + verify(req, res, buf) { + req.rawBody = buf; + }, + }) +); +app.use( + express.urlencoded({ + extended: true, + limit: appConfig.requestBodySizeLimit, + verify(req, res, buf) { + req.rawBody = buf; + }, + }) +); +app.use(cors(corsOptions)); + +configurePassport(app); + +app.use('/', router); + +webUIHandler(app); + +// catch 404 and forward to error handler +app.use(function (req, res, next) { + next(createError(404)); +}); + +Sentry.attachErrorHandler(app); + +app.use(errorHandler); + +export default app; diff --git a/packages/backend/src/apps/airtable/actions/create-record/index.js b/packages/backend/src/apps/airtable/actions/create-record/index.js new file mode 100644 index 0000000..554015b --- /dev/null +++ b/packages/backend/src/apps/airtable/actions/create-record/index.js @@ -0,0 +1,92 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create record', + key: 'createRecord', + description: 'Creates a new record with fields that automatically populate.', + arguments: [ + { + label: 'Base', + key: 'baseId', + type: 'dropdown', + required: true, + description: 'Base in which to create the record.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBases', + }, + ], + }, + }, + { + label: 'Table', + key: 'tableId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.baseId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTables', + }, + { + name: 'parameters.baseId', + value: '{parameters.baseId}', + }, + ], + }, + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listFields', + }, + { + name: 'parameters.baseId', + value: '{parameters.baseId}', + }, + { + name: 'parameters.tableId', + value: '{parameters.tableId}', + }, + ], + }, + }, + ], + + async run($) { + const { baseId, tableId, ...rest } = $.step.parameters; + + const fields = Object.entries(rest).reduce((result, [key, value]) => { + if (Array.isArray(value)) { + result[key] = value.map((item) => item.value); + } else if (value !== '') { + result[key] = value; + } + return result; + }, {}); + + const body = { + typecast: true, + fields, + }; + + const { data } = await $.http.post(`/v0/${baseId}/${tableId}`, body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/airtable/actions/find-record/index.js b/packages/backend/src/apps/airtable/actions/find-record/index.js new file mode 100644 index 0000000..ad0f1ea --- /dev/null +++ b/packages/backend/src/apps/airtable/actions/find-record/index.js @@ -0,0 +1,174 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { URLSearchParams } from 'url'; + +export default defineAction({ + name: 'Find record', + key: 'findRecord', + description: + "Finds a record using simple field search or use Airtable's formula syntax to find a matching record.", + arguments: [ + { + label: 'Base', + key: 'baseId', + type: 'dropdown', + required: true, + description: 'Base in which to create the record.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBases', + }, + ], + }, + }, + { + label: 'Table', + key: 'tableId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.baseId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTables', + }, + { + name: 'parameters.baseId', + value: '{parameters.baseId}', + }, + ], + }, + }, + { + label: 'Search by field', + key: 'tableField', + type: 'dropdown', + required: false, + dependsOn: ['parameters.baseId', 'parameters.tableId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTableFields', + }, + { + name: 'parameters.baseId', + value: '{parameters.baseId}', + }, + { + name: 'parameters.tableId', + value: '{parameters.tableId}', + }, + ], + }, + }, + { + label: 'Search Value', + key: 'searchValue', + type: 'string', + required: false, + variables: true, + description: + 'The value of unique identifier for the record. For date values, please use the ISO format (e.g., "YYYY-MM-DD").', + }, + { + label: 'Search for exact match?', + key: 'exactMatch', + type: 'dropdown', + required: true, + description: '', + variables: true, + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], + }, + { + label: 'Search Formula', + key: 'searchFormula', + type: 'string', + required: false, + variables: true, + description: + 'Instead, you have the option to use an Airtable search formula for locating records according to sophisticated criteria and across various fields.', + }, + { + label: 'Limit to View', + key: 'limitToView', + type: 'dropdown', + required: false, + dependsOn: ['parameters.baseId', 'parameters.tableId'], + description: + 'You have the choice to restrict the search to a particular view ID if desired.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTableViews', + }, + { + name: 'parameters.baseId', + value: '{parameters.baseId}', + }, + { + name: 'parameters.tableId', + value: '{parameters.tableId}', + }, + ], + }, + }, + ], + + async run($) { + const { + baseId, + tableId, + tableField, + searchValue, + exactMatch, + searchFormula, + limitToView, + } = $.step.parameters; + + let filterByFormula; + + if (tableField && searchValue) { + filterByFormula = + exactMatch === 'true' + ? `{${tableField}} = '${searchValue}'` + : `LOWER({${tableField}}) = LOWER('${searchValue}')`; + } else { + filterByFormula = searchFormula; + } + + const body = new URLSearchParams({ + filterByFormula, + view: limitToView, + }); + + const { data } = await $.http.post( + `/v0/${baseId}/${tableId}/listRecords`, + body + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/airtable/actions/index.js b/packages/backend/src/apps/airtable/actions/index.js new file mode 100644 index 0000000..bb3c63c --- /dev/null +++ b/packages/backend/src/apps/airtable/actions/index.js @@ -0,0 +1,4 @@ +import createRecord from './create-record/index.js'; +import findRecord from './find-record/index.js'; + +export default [createRecord, findRecord]; diff --git a/packages/backend/src/apps/airtable/assets/favicon.svg b/packages/backend/src/apps/airtable/assets/favicon.svg new file mode 100644 index 0000000..867c3b5 --- /dev/null +++ b/packages/backend/src/apps/airtable/assets/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/backend/src/apps/airtable/auth/generate-auth-url.js b/packages/backend/src/apps/airtable/auth/generate-auth-url.js new file mode 100644 index 0000000..70d5d7e --- /dev/null +++ b/packages/backend/src/apps/airtable/auth/generate-auth-url.js @@ -0,0 +1,38 @@ +import crypto from 'crypto'; +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const state = crypto.randomBytes(100).toString('base64url'); + const codeVerifier = crypto.randomBytes(96).toString('base64url'); + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: authScope.join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + const url = `https://airtable.com/oauth2/v1/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + originalCodeChallenge: codeChallenge, + originalState: state, + codeVerifier, + }); +} diff --git a/packages/backend/src/apps/airtable/auth/index.js b/packages/backend/src/apps/airtable/auth/index.js new file mode 100644 index 0000000..6422369 --- /dev/null +++ b/packages/backend/src/apps/airtable/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/airtable/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Airtable, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/airtable/auth/is-still-verified.js b/packages/backend/src/apps/airtable/auth/is-still-verified.js new file mode 100644 index 0000000..0896289 --- /dev/null +++ b/packages/backend/src/apps/airtable/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/airtable/auth/refresh-token.js b/packages/backend/src/apps/airtable/auth/refresh-token.js new file mode 100644 index 0000000..6f73550 --- /dev/null +++ b/packages/backend/src/apps/airtable/auth/refresh-token.js @@ -0,0 +1,40 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const basicAuthToken = Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64'); + + const { data } = await $.http.post( + 'https://airtable.com/oauth2/v1/token', + params.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuthToken}`, + }, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + await $.auth.set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + refreshExpiresIn: data.refresh_expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/airtable/auth/verify-credentials.js b/packages/backend/src/apps/airtable/auth/verify-credentials.js new file mode 100644 index 0000000..f2ef811 --- /dev/null +++ b/packages/backend/src/apps/airtable/auth/verify-credentials.js @@ -0,0 +1,56 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + if ($.auth.data.originalState !== $.auth.data.state) { + throw new Error("The 'state' parameter does not match."); + } + if ($.auth.data.originalCodeChallenge !== $.auth.data.code_challenge) { + throw new Error("The 'code challenge' parameter does not match."); + } + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const basicAuthToken = Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64'); + + const { data } = await $.http.post( + 'https://airtable.com/oauth2/v1/token', + { + code: $.auth.data.code, + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + code_verifier: $.auth.data.codeVerifier, + }, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuthToken}`, + }, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + expiresIn: data.expires_in, + refreshExpiresIn: data.refresh_expires_in, + refreshToken: data.refresh_token, + screenName: currentUser.email, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/airtable/common/add-auth-header.js b/packages/backend/src/apps/airtable/common/add-auth-header.js new file mode 100644 index 0000000..f957ebf --- /dev/null +++ b/packages/backend/src/apps/airtable/common/add-auth-header.js @@ -0,0 +1,12 @@ +const addAuthHeader = ($, requestConfig) => { + if ( + !requestConfig.additionalProperties?.skipAddingAuthHeader && + $.auth.data?.accessToken + ) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/airtable/common/auth-scope.js b/packages/backend/src/apps/airtable/common/auth-scope.js new file mode 100644 index 0000000..8b4cbca --- /dev/null +++ b/packages/backend/src/apps/airtable/common/auth-scope.js @@ -0,0 +1,12 @@ +const authScope = [ + 'data.records:read', + 'data.records:write', + 'data.recordComments:read', + 'data.recordComments:write', + 'schema.bases:read', + 'schema.bases:write', + 'user.email:read', + 'webhook:manage', +]; + +export default authScope; diff --git a/packages/backend/src/apps/airtable/common/get-current-user.js b/packages/backend/src/apps/airtable/common/get-current-user.js new file mode 100644 index 0000000..c04f16a --- /dev/null +++ b/packages/backend/src/apps/airtable/common/get-current-user.js @@ -0,0 +1,6 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get('/v0/meta/whoami'); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/airtable/dynamic-data/index.js b/packages/backend/src/apps/airtable/dynamic-data/index.js new file mode 100644 index 0000000..c12f341 --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-data/index.js @@ -0,0 +1,6 @@ +import listBases from './list-bases/index.js'; +import listTableFields from './list-table-fields/index.js'; +import listTableViews from './list-table-views/index.js'; +import listTables from './list-tables/index.js'; + +export default [listBases, listTableFields, listTableViews, listTables]; diff --git a/packages/backend/src/apps/airtable/dynamic-data/list-bases/index.js b/packages/backend/src/apps/airtable/dynamic-data/list-bases/index.js new file mode 100644 index 0000000..2f07569 --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-data/list-bases/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List bases', + key: 'listBases', + + async run($) { + const bases = { + data: [], + }; + + const params = {}; + + do { + const { data } = await $.http.get('/v0/meta/bases', { params }); + params.offset = data.offset; + + if (data?.bases) { + for (const base of data.bases) { + bases.data.push({ + value: base.id, + name: base.name, + }); + } + } + } while (params.offset); + + return bases; + }, +}; diff --git a/packages/backend/src/apps/airtable/dynamic-data/list-table-fields/index.js b/packages/backend/src/apps/airtable/dynamic-data/list-table-fields/index.js new file mode 100644 index 0000000..3f96d12 --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-data/list-table-fields/index.js @@ -0,0 +1,39 @@ +export default { + name: 'List table fields', + key: 'listTableFields', + + async run($) { + const tableFields = { + data: [], + }; + const { baseId, tableId } = $.step.parameters; + + if (!baseId) { + return tableFields; + } + + const params = {}; + + do { + const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, { + params, + }); + params.offset = data.offset; + + if (data?.tables) { + for (const table of data.tables) { + if (table.id === tableId) { + table.fields.forEach((field) => { + tableFields.data.push({ + value: field.name, + name: field.name, + }); + }); + } + } + } + } while (params.offset); + + return tableFields; + }, +}; diff --git a/packages/backend/src/apps/airtable/dynamic-data/list-table-views/index.js b/packages/backend/src/apps/airtable/dynamic-data/list-table-views/index.js new file mode 100644 index 0000000..d2ec912 --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-data/list-table-views/index.js @@ -0,0 +1,39 @@ +export default { + name: 'List table views', + key: 'listTableViews', + + async run($) { + const tableViews = { + data: [], + }; + const { baseId, tableId } = $.step.parameters; + + if (!baseId) { + return tableViews; + } + + const params = {}; + + do { + const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, { + params, + }); + params.offset = data.offset; + + if (data?.tables) { + for (const table of data.tables) { + if (table.id === tableId) { + table.views.forEach((view) => { + tableViews.data.push({ + value: view.id, + name: view.name, + }); + }); + } + } + } + } while (params.offset); + + return tableViews; + }, +}; diff --git a/packages/backend/src/apps/airtable/dynamic-data/list-tables/index.js b/packages/backend/src/apps/airtable/dynamic-data/list-tables/index.js new file mode 100644 index 0000000..90d6b4c --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-data/list-tables/index.js @@ -0,0 +1,35 @@ +export default { + name: 'List tables', + key: 'listTables', + + async run($) { + const tables = { + data: [], + }; + const baseId = $.step.parameters.baseId; + + if (!baseId) { + return tables; + } + + const params = {}; + + do { + const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, { + params, + }); + params.offset = data.offset; + + if (data?.tables) { + for (const table of data.tables) { + tables.data.push({ + value: table.id, + name: table.name, + }); + } + } + } while (params.offset); + + return tables; + }, +}; diff --git a/packages/backend/src/apps/airtable/dynamic-fields/index.js b/packages/backend/src/apps/airtable/dynamic-fields/index.js new file mode 100644 index 0000000..5d97313 --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listFields from './list-fields/index.js'; + +export default [listFields]; diff --git a/packages/backend/src/apps/airtable/dynamic-fields/list-fields/index.js b/packages/backend/src/apps/airtable/dynamic-fields/list-fields/index.js new file mode 100644 index 0000000..704d194 --- /dev/null +++ b/packages/backend/src/apps/airtable/dynamic-fields/list-fields/index.js @@ -0,0 +1,86 @@ +const hasValue = (value) => value !== null && value !== undefined; + +export default { + name: 'List fields', + key: 'listFields', + + async run($) { + const options = []; + const { baseId, tableId } = $.step.parameters; + + if (!hasValue(baseId) || !hasValue(tableId)) { + return; + } + + const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`); + + const selectedTable = data.tables.find((table) => table.id === tableId); + + if (!selectedTable) return; + + selectedTable.fields.forEach((field) => { + if (field.type === 'singleSelect') { + options.push({ + label: field.name, + key: field.name, + type: 'dropdown', + required: false, + variables: true, + options: field.options.choices.map((choice) => ({ + label: choice.name, + value: choice.id, + })), + }); + } else if (field.type === 'multipleSelects') { + options.push({ + label: field.name, + key: field.name, + type: 'dynamic', + required: false, + variables: true, + fields: [ + { + label: 'Value', + key: 'value', + type: 'dropdown', + required: false, + variables: true, + options: field.options.choices.map((choice) => ({ + label: choice.name, + value: choice.id, + })), + }, + ], + }); + } else if (field.type === 'checkbox') { + options.push({ + label: field.name, + key: field.name, + type: 'dropdown', + required: false, + variables: true, + options: [ + { + label: 'Yes', + value: 'true', + }, + { + label: 'No', + value: 'false', + }, + ], + }); + } else { + options.push({ + label: field.name, + key: field.name, + type: 'string', + required: false, + variables: true, + }); + } + }); + + return options; + }, +}; diff --git a/packages/backend/src/apps/airtable/index.js b/packages/backend/src/apps/airtable/index.js new file mode 100644 index 0000000..8910add --- /dev/null +++ b/packages/backend/src/apps/airtable/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'Airtable', + key: 'airtable', + baseUrl: 'https://airtable.com', + apiBaseUrl: 'https://api.airtable.com', + iconUrl: '{BASE_URL}/apps/airtable/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/airtable/connection', + primaryColor: '#FFBF00', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, + dynamicFields, +}); diff --git a/packages/backend/src/apps/anthropic/actions/index.js b/packages/backend/src/apps/anthropic/actions/index.js new file mode 100644 index 0000000..92d67c2 --- /dev/null +++ b/packages/backend/src/apps/anthropic/actions/index.js @@ -0,0 +1,3 @@ +import sendMessage from './send-message/index.js'; + +export default [sendMessage]; diff --git a/packages/backend/src/apps/anthropic/actions/send-message/index.js b/packages/backend/src/apps/anthropic/actions/send-message/index.js new file mode 100644 index 0000000..510dd04 --- /dev/null +++ b/packages/backend/src/apps/anthropic/actions/send-message/index.js @@ -0,0 +1,124 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send message', + key: 'sendMessage', + description: + 'Sends a structured list of input messages with text content, and the model will generate the next message in the conversation.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + description: 'The model that will complete your prompt.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'Add or remove messages as needed', + value: [{ role: 'assistant', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: true, + variables: true, + description: 'The maximum number of tokens to generate before stopping.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + value: '1.0', + description: + 'Amount of randomness injected into the response. Defaults to 1.0. Ranges from 0.0 to 1.0. Use temperature closer to 0.0 for analytical / multiple choice, and closer to 1.0 for creative and generative tasks.', + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: + 'Custom text sequences that will cause the model to stop generating.', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const payload = { + model: $.step.parameters.model, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop_sequences: nonEmptyStopSequences, + messages: $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + }; + + const { data } = await $.http.post('/v1/messages', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/anthropic/assets/favicon.svg b/packages/backend/src/apps/anthropic/assets/favicon.svg new file mode 100644 index 0000000..affdade --- /dev/null +++ b/packages/backend/src/apps/anthropic/assets/favicon.svg @@ -0,0 +1,8 @@ + + + Anthropic + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/anthropic/auth/index.js b/packages/backend/src/apps/anthropic/auth/index.js new file mode 100644 index 0000000..947c8f8 --- /dev/null +++ b/packages/backend/src/apps/anthropic/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Anthropic AI API key of your account.', + docUrl: 'https://automatisch.io/docs/anthropic#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/anthropic/auth/is-still-verified.js b/packages/backend/src/apps/anthropic/auth/is-still-verified.js new file mode 100644 index 0000000..531fc23 --- /dev/null +++ b/packages/backend/src/apps/anthropic/auth/is-still-verified.js @@ -0,0 +1,7 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/anthropic/auth/verify-credentials.js b/packages/backend/src/apps/anthropic/auth/verify-credentials.js new file mode 100644 index 0000000..7f43f88 --- /dev/null +++ b/packages/backend/src/apps/anthropic/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js b/packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js new file mode 100644 index 0000000..9ff91ce --- /dev/null +++ b/packages/backend/src/apps/anthropic/common/add-anthropic-version-header.js @@ -0,0 +1,7 @@ +const addAuthHeader = ($, requestConfig) => { + requestConfig.headers['anthropic-version'] = '2023-06-01'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/anthropic/common/add-auth-header.js b/packages/backend/src/apps/anthropic/common/add-auth-header.js new file mode 100644 index 0000000..01bcae1 --- /dev/null +++ b/packages/backend/src/apps/anthropic/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['x-api-key'] = $.auth.data.apiKey; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/anthropic/dynamic-data/index.js b/packages/backend/src/apps/anthropic/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/anthropic/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js b/packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js new file mode 100644 index 0000000..f47f5fb --- /dev/null +++ b/packages/backend/src/apps/anthropic/dynamic-data/list-models/index.js @@ -0,0 +1,31 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const models = { + data: [], + }; + + const params = { + limit: 999, + }; + + let hasMore = false; + + do { + const { data } = await $.http.get('/v1/models', { params }); + params.after_id = data.last_id; + hasMore = data.has_more; + + for (const base of data.data) { + models.data.push({ + value: base.id, + name: base.display_name, + }); + } + } while (hasMore); + + return models; + }, +}; diff --git a/packages/backend/src/apps/anthropic/index.js b/packages/backend/src/apps/anthropic/index.js new file mode 100644 index 0000000..65f29ba --- /dev/null +++ b/packages/backend/src/apps/anthropic/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import addAnthropicVersionHeader from './common/add-anthropic-version-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Anthropic', + key: 'anthropic', + baseUrl: 'https://anthropic.com', + apiBaseUrl: 'https://api.anthropic.com', + iconUrl: '{BASE_URL}/apps/anthropic/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/anthropic/connection', + primaryColor: '#181818', + supportsConnections: true, + beforeRequest: [addAuthHeader, addAnthropicVersionHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/appwrite/assets/favicon.svg b/packages/backend/src/apps/appwrite/assets/favicon.svg new file mode 100644 index 0000000..63bf0f2 --- /dev/null +++ b/packages/backend/src/apps/appwrite/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/appwrite/auth/index.js b/packages/backend/src/apps/appwrite/auth/index.js new file mode 100644 index 0000000..dfdd374 --- /dev/null +++ b/packages/backend/src/apps/appwrite/auth/index.js @@ -0,0 +1,65 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'projectId', + label: 'Project ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Project ID of your Appwrite project.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API key of your Appwrite project.', + clickToCopy: false, + }, + { + key: 'instanceUrl', + label: 'Appwrite instance URL', + type: 'string', + required: false, + readOnly: false, + placeholder: '', + description: '', + clickToCopy: true, + }, + { + key: 'host', + label: 'Host Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Host name of your Appwrite project.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/appwrite/auth/is-still-verified.js b/packages/backend/src/apps/appwrite/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/appwrite/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/appwrite/auth/verify-credentials.js b/packages/backend/src/apps/appwrite/auth/verify-credentials.js new file mode 100644 index 0000000..3cd6169 --- /dev/null +++ b/packages/backend/src/apps/appwrite/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/users'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/appwrite/common/add-auth-header.js b/packages/backend/src/apps/appwrite/common/add-auth-header.js new file mode 100644 index 0000000..1bec610 --- /dev/null +++ b/packages/backend/src/apps/appwrite/common/add-auth-header.js @@ -0,0 +1,16 @@ +const addAuthHeader = ($, requestConfig) => { + requestConfig.headers['Content-Type'] = 'application/json'; + + if ($.auth.data?.apiKey && $.auth.data?.projectId) { + requestConfig.headers['X-Appwrite-Project'] = $.auth.data.projectId; + requestConfig.headers['X-Appwrite-Key'] = $.auth.data.apiKey; + } + + if ($.auth.data?.host) { + requestConfig.headers['Host'] = $.auth.data.host; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/appwrite/common/set-base-url.js b/packages/backend/src/apps/appwrite/common/set-base-url.js new file mode 100644 index 0000000..35a7a95 --- /dev/null +++ b/packages/backend/src/apps/appwrite/common/set-base-url.js @@ -0,0 +1,13 @@ +const setBaseUrl = ($, requestConfig) => { + const instanceUrl = $.auth.data.instanceUrl; + + if (instanceUrl) { + requestConfig.baseURL = instanceUrl; + } else if ($.app.apiBaseUrl) { + requestConfig.baseURL = $.app.apiBaseUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/appwrite/dynamic-data/index.js b/packages/backend/src/apps/appwrite/dynamic-data/index.js new file mode 100644 index 0000000..45eecdb --- /dev/null +++ b/packages/backend/src/apps/appwrite/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listCollections from './list-collections/index.js'; +import listDatabases from './list-databases/index.js'; + +export default [listCollections, listDatabases]; diff --git a/packages/backend/src/apps/appwrite/dynamic-data/list-collections/index.js b/packages/backend/src/apps/appwrite/dynamic-data/list-collections/index.js new file mode 100644 index 0000000..00a839f --- /dev/null +++ b/packages/backend/src/apps/appwrite/dynamic-data/list-collections/index.js @@ -0,0 +1,44 @@ +export default { + name: 'List collections', + key: 'listCollections', + + async run($) { + const collections = { + data: [], + }; + const databaseId = $.step.parameters.databaseId; + + if (!databaseId) { + return collections; + } + + const params = { + queries: [ + JSON.stringify({ + method: 'orderAsc', + attribute: 'name', + }), + JSON.stringify({ + method: 'limit', + values: [100], + }), + ], + }; + + const { data } = await $.http.get( + `/v1/databases/${databaseId}/collections`, + { params } + ); + + if (data?.collections) { + for (const collection of data.collections) { + collections.data.push({ + value: collection.$id, + name: collection.name, + }); + } + } + + return collections; + }, +}; diff --git a/packages/backend/src/apps/appwrite/dynamic-data/list-databases/index.js b/packages/backend/src/apps/appwrite/dynamic-data/list-databases/index.js new file mode 100644 index 0000000..225e4dd --- /dev/null +++ b/packages/backend/src/apps/appwrite/dynamic-data/list-databases/index.js @@ -0,0 +1,36 @@ +export default { + name: 'List databases', + key: 'listDatabases', + + async run($) { + const databases = { + data: [], + }; + + const params = { + queries: [ + JSON.stringify({ + method: 'orderAsc', + attribute: 'name', + }), + JSON.stringify({ + method: 'limit', + values: [100], + }), + ], + }; + + const { data } = await $.http.get('/v1/databases', { params }); + + if (data?.databases) { + for (const database of data.databases) { + databases.data.push({ + value: database.$id, + name: database.name, + }); + } + } + + return databases; + }, +}; diff --git a/packages/backend/src/apps/appwrite/index.js b/packages/backend/src/apps/appwrite/index.js new file mode 100644 index 0000000..199203e --- /dev/null +++ b/packages/backend/src/apps/appwrite/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Appwrite', + key: 'appwrite', + baseUrl: 'https://appwrite.io', + apiBaseUrl: 'https://cloud.appwrite.io', + iconUrl: '{BASE_URL}/apps/appwrite/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/appwrite/connection', + primaryColor: '#FD366E', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/appwrite/triggers/index.js b/packages/backend/src/apps/appwrite/triggers/index.js new file mode 100644 index 0000000..30d4b6c --- /dev/null +++ b/packages/backend/src/apps/appwrite/triggers/index.js @@ -0,0 +1,3 @@ +import newDocuments from './new-documents/index.js'; + +export default [newDocuments]; diff --git a/packages/backend/src/apps/appwrite/triggers/new-documents/index.js b/packages/backend/src/apps/appwrite/triggers/new-documents/index.js new file mode 100644 index 0000000..b006862 --- /dev/null +++ b/packages/backend/src/apps/appwrite/triggers/new-documents/index.js @@ -0,0 +1,104 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New documents', + key: 'newDocuments', + pollInterval: 15, + description: 'Triggers when a new document is created.', + arguments: [ + { + label: 'Database', + key: 'databaseId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDatabases', + }, + ], + }, + }, + { + label: 'Collection', + key: 'collectionId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.databaseId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCollections', + }, + { + name: 'parameters.databaseId', + value: '{parameters.databaseId}', + }, + ], + }, + }, + ], + + async run($) { + const { databaseId, collectionId } = $.step.parameters; + + const limit = 1; + let lastDocumentId = undefined; + let offset = 0; + let documentCount = 0; + + do { + const params = { + queries: [ + JSON.stringify({ + method: 'orderDesc', + attribute: '$createdAt', + }), + JSON.stringify({ + method: 'limit', + values: [limit], + }), + // An invalid cursor shouldn't be sent. + lastDocumentId && + JSON.stringify({ + method: 'cursorAfter', + values: [lastDocumentId], + }), + ].filter(Boolean), + }; + + const { data } = await $.http.get( + `/v1/databases/${databaseId}/collections/${collectionId}/documents`, + { params } + ); + + const documents = data?.documents; + documentCount = documents?.length; + offset = offset + limit; + lastDocumentId = documents[documentCount - 1]?.$id; + + if (!documentCount) { + return; + } + + for (const document of documents) { + $.pushTriggerItem({ + raw: document, + meta: { + internalId: document.$id, + }, + }); + } + } while (documentCount === limit); + }, +}); diff --git a/packages/backend/src/apps/azure-openai/actions/index.js b/packages/backend/src/apps/azure-openai/actions/index.js new file mode 100644 index 0000000..44f6cbc --- /dev/null +++ b/packages/backend/src/apps/azure-openai/actions/index.js @@ -0,0 +1,3 @@ +import sendPrompt from './send-prompt/index.js'; + +export default [sendPrompt]; diff --git a/packages/backend/src/apps/azure-openai/actions/send-prompt/index.js b/packages/backend/src/apps/azure-openai/actions/send-prompt/index.js new file mode 100644 index 0000000..91ae307 --- /dev/null +++ b/packages/backend/src/apps/azure-openai/actions/send-prompt/index.js @@ -0,0 +1,97 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send prompt', + key: 'sendPrompt', + description: 'Creates a completion for the provided prompt and parameters.', + arguments: [ + { + label: 'Prompt', + key: 'prompt', + type: 'string', + required: true, + variables: true, + description: 'The text to analyze.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use, between 0 and 2. Higher values means the model will take more risks. Try 0.9 for more creative applications, and 0 (argmax sampling) for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Stop Sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + description: + 'Single stop sequence where the API will stop generating further tokens. The returned text will not contain the stop sequence.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.`, + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + prompt: $.step.parameters.prompt, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: $.step.parameters.stopSequence || null, + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + + const { data } = await $.http.post( + `/deployments/${$.auth.data.deploymentId}/completions`, + payload + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/azure-openai/assets/favicon.svg b/packages/backend/src/apps/azure-openai/assets/favicon.svg new file mode 100644 index 0000000..b62b84e --- /dev/null +++ b/packages/backend/src/apps/azure-openai/assets/favicon.svg @@ -0,0 +1,6 @@ + + OpenAI + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/azure-openai/auth/index.js b/packages/backend/src/apps/azure-openai/auth/index.js new file mode 100644 index 0000000..3de895d --- /dev/null +++ b/packages/backend/src/apps/azure-openai/auth/index.js @@ -0,0 +1,58 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'yourResourceName', + label: 'Your Resource Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'The name of your Azure OpenAI Resource.', + docUrl: 'https://automatisch.io/docs/azure-openai#your-resource-name', + clickToCopy: false, + }, + { + key: 'deploymentId', + label: 'Deployment ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'The deployment name you chose when you deployed the model.', + docUrl: 'https://automatisch.io/docs/azure-openai#deployment-id', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Azure OpenAI API key of your account.', + docUrl: 'https://automatisch.io/docs/azure-openai#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/azure-openai/auth/is-still-verified.js b/packages/backend/src/apps/azure-openai/auth/is-still-verified.js new file mode 100644 index 0000000..a88adf3 --- /dev/null +++ b/packages/backend/src/apps/azure-openai/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/fine_tuning/jobs'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/azure-openai/auth/verify-credentials.js b/packages/backend/src/apps/azure-openai/auth/verify-credentials.js new file mode 100644 index 0000000..3f0e9dd --- /dev/null +++ b/packages/backend/src/apps/azure-openai/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/fine_tuning/jobs'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/azure-openai/common/add-auth-header.js b/packages/backend/src/apps/azure-openai/common/add-auth-header.js new file mode 100644 index 0000000..9e36706 --- /dev/null +++ b/packages/backend/src/apps/azure-openai/common/add-auth-header.js @@ -0,0 +1,13 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['api-key'] = $.auth.data.apiKey; + } + + requestConfig.params = { + 'api-version': '2023-10-01-preview', + }; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/azure-openai/common/set-base-url.js b/packages/backend/src/apps/azure-openai/common/set-base-url.js new file mode 100644 index 0000000..222ccf7 --- /dev/null +++ b/packages/backend/src/apps/azure-openai/common/set-base-url.js @@ -0,0 +1,11 @@ +const setBaseUrl = ($, requestConfig) => { + const yourResourceName = $.auth.data.yourResourceName; + + if (yourResourceName) { + requestConfig.baseURL = `https://${yourResourceName}.openai.azure.com/openai`; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/azure-openai/index.js b/packages/backend/src/apps/azure-openai/index.js new file mode 100644 index 0000000..a997c3f --- /dev/null +++ b/packages/backend/src/apps/azure-openai/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import setBaseUrl from './common/set-base-url.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Azure OpenAI', + key: 'azure-openai', + baseUrl: + 'https://azure.microsoft.com/en-us/products/ai-services/openai-service', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/azure-openai/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/azure-openai/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/brave-search/actions/index.js b/packages/backend/src/apps/brave-search/actions/index.js new file mode 100644 index 0000000..b7bb145 --- /dev/null +++ b/packages/backend/src/apps/brave-search/actions/index.js @@ -0,0 +1,3 @@ +import webSearch from './web-search/index.js'; + +export default [webSearch]; diff --git a/packages/backend/src/apps/brave-search/actions/web-search/index.js b/packages/backend/src/apps/brave-search/actions/web-search/index.js new file mode 100644 index 0000000..10f3dfd --- /dev/null +++ b/packages/backend/src/apps/brave-search/actions/web-search/index.js @@ -0,0 +1,52 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Web search', + key: 'webSearch', + description: 'Queries Brave Search and get back search results from the web.', + arguments: [ + { + label: 'Query', + key: 'q', + type: 'string', + required: true, + variables: true, + description: 'The search query term.', + }, + { + label: 'Safe search', + key: 'safesearch', + type: 'dropdown', + required: true, + description: 'Add or remove messages as needed', + value: 'moderate', + options: [ + { + label: 'Off', + value: 'off', + }, + { + label: 'Moderate', + value: 'moderate', + }, + { + label: 'Strict', + value: 'strict', + }, + ], + }, + ], + + async run($) { + const params = { + q: $.step.parameters.q, + safesearch: $.step.parameters.safesearch, + }; + + const { data } = await $.http.get('/v1/web/search', { params }); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/brave-search/assets/favicon.svg b/packages/backend/src/apps/brave-search/assets/favicon.svg new file mode 100644 index 0000000..8f95398 --- /dev/null +++ b/packages/backend/src/apps/brave-search/assets/favicon.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/brave-search/auth/index.js b/packages/backend/src/apps/brave-search/auth/index.js new file mode 100644 index 0000000..859a583 --- /dev/null +++ b/packages/backend/src/apps/brave-search/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Brave Search API key of your account.', + docUrl: 'https://automatisch.io/docs/brave-search#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/brave-search/auth/is-still-verified.js b/packages/backend/src/apps/brave-search/auth/is-still-verified.js new file mode 100644 index 0000000..3f85395 --- /dev/null +++ b/packages/backend/src/apps/brave-search/auth/is-still-verified.js @@ -0,0 +1,5 @@ +const isStillVerified = async () => { + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/brave-search/auth/verify-credentials.js b/packages/backend/src/apps/brave-search/auth/verify-credentials.js new file mode 100644 index 0000000..07e4f02 --- /dev/null +++ b/packages/backend/src/apps/brave-search/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async () => { + return true; +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/brave-search/common/add-accept-header.js b/packages/backend/src/apps/brave-search/common/add-accept-header.js new file mode 100644 index 0000000..47c033a --- /dev/null +++ b/packages/backend/src/apps/brave-search/common/add-accept-header.js @@ -0,0 +1,7 @@ +const addContentTypeHeader = ($, requestConfig) => { + requestConfig.headers.accept = 'application/json'; + + return requestConfig; +}; + +export default addContentTypeHeader; diff --git a/packages/backend/src/apps/brave-search/common/add-auth-header.js b/packages/backend/src/apps/brave-search/common/add-auth-header.js new file mode 100644 index 0000000..14b60d8 --- /dev/null +++ b/packages/backend/src/apps/brave-search/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['X-Subscription-Token'] = $.auth.data.apiKey; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/brave-search/dynamic-data/index.js b/packages/backend/src/apps/brave-search/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/brave-search/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/brave-search/dynamic-data/list-models/index.js b/packages/backend/src/apps/brave-search/dynamic-data/list-models/index.js new file mode 100644 index 0000000..f47f5fb --- /dev/null +++ b/packages/backend/src/apps/brave-search/dynamic-data/list-models/index.js @@ -0,0 +1,31 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const models = { + data: [], + }; + + const params = { + limit: 999, + }; + + let hasMore = false; + + do { + const { data } = await $.http.get('/v1/models', { params }); + params.after_id = data.last_id; + hasMore = data.has_more; + + for (const base of data.data) { + models.data.push({ + value: base.id, + name: base.display_name, + }); + } + } while (hasMore); + + return models; + }, +}; diff --git a/packages/backend/src/apps/brave-search/index.js b/packages/backend/src/apps/brave-search/index.js new file mode 100644 index 0000000..f85615e --- /dev/null +++ b/packages/backend/src/apps/brave-search/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import addAcceptHeader from './common/add-accept-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Brave Search', + key: 'brave-search', + baseUrl: 'https://search.brave.com', + apiBaseUrl: 'https://api.search.brave.com/res', + iconUrl: '{BASE_URL}/apps/brave-search/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/brave-search/connection', + primaryColor: '#181818', + supportsConnections: true, + beforeRequest: [addAuthHeader, addAcceptHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/carbone/actions/add-template/index.js b/packages/backend/src/apps/carbone/actions/add-template/index.js new file mode 100644 index 0000000..1a56bd8 --- /dev/null +++ b/packages/backend/src/apps/carbone/actions/add-template/index.js @@ -0,0 +1,35 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Add Template', + key: 'addTemplate', + description: + 'Creates an attachment of a specified object by given parent ID.', + arguments: [ + { + label: 'Template Data', + key: 'templateData', + type: 'string', + required: true, + variables: true, + description: 'The content of your new Template in XML/HTML format.', + }, + ], + + async run($) { + const templateData = $.step.parameters.templateData; + + const base64Data = Buffer.from(templateData).toString('base64'); + const dataURI = `data:application/xml;base64,${base64Data}`; + + const body = JSON.stringify({ template: dataURI }); + + const response = await $.http.post('/template', body, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/carbone/actions/index.js b/packages/backend/src/apps/carbone/actions/index.js new file mode 100644 index 0000000..4c513e7 --- /dev/null +++ b/packages/backend/src/apps/carbone/actions/index.js @@ -0,0 +1,3 @@ +import addTemplate from './add-template/index.js'; + +export default [addTemplate]; diff --git a/packages/backend/src/apps/carbone/assets/favicon.svg b/packages/backend/src/apps/carbone/assets/favicon.svg new file mode 100644 index 0000000..cadf2c9 --- /dev/null +++ b/packages/backend/src/apps/carbone/assets/favicon.svg @@ -0,0 +1,444 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/carbone/auth/index.js b/packages/backend/src/apps/carbone/auth/index.js new file mode 100644 index 0000000..736516e --- /dev/null +++ b/packages/backend/src/apps/carbone/auth/index.js @@ -0,0 +1,33 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Carbone API key of your account.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/carbone/auth/is-still-verified.js b/packages/backend/src/apps/carbone/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/carbone/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/carbone/auth/verify-credentials.js b/packages/backend/src/apps/carbone/auth/verify-credentials.js new file mode 100644 index 0000000..7bf000b --- /dev/null +++ b/packages/backend/src/apps/carbone/auth/verify-credentials.js @@ -0,0 +1,10 @@ +const verifyCredentials = async ($) => { + await $.http.get('/templates'); + + await $.auth.set({ + screenName: $.auth.data.screenName, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/carbone/common/add-auth-header.js b/packages/backend/src/apps/carbone/common/add-auth-header.js new file mode 100644 index 0000000..ced8898 --- /dev/null +++ b/packages/backend/src/apps/carbone/common/add-auth-header.js @@ -0,0 +1,10 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + requestConfig.headers['carbone-version'] = '4'; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/carbone/index.js b/packages/backend/src/apps/carbone/index.js new file mode 100644 index 0000000..18abec9 --- /dev/null +++ b/packages/backend/src/apps/carbone/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Carbone', + key: 'carbone', + iconUrl: '{BASE_URL}/apps/carbone/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/carbone/connection', + supportsConnections: true, + baseUrl: 'https://carbone.io', + apiBaseUrl: 'https://api.carbone.io', + primaryColor: '#6f42c1', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/clickup/actions/create-folder/index.js b/packages/backend/src/apps/clickup/actions/create-folder/index.js new file mode 100644 index 0000000..2db2acd --- /dev/null +++ b/packages/backend/src/apps/clickup/actions/create-folder/index.js @@ -0,0 +1,72 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create folder', + key: 'createFolder', + description: 'Creates a new folder.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + { + label: 'Folder Name', + key: 'folderName', + type: 'string', + required: true, + description: '', + variables: true, + }, + ], + + async run($) { + const { spaceId, folderName } = $.step.parameters; + + const body = { + name: folderName, + }; + + const { data } = await $.http.post(`/v2/space/${spaceId}/folder`, body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/clickup/actions/create-list/index.js b/packages/backend/src/apps/clickup/actions/create-list/index.js new file mode 100644 index 0000000..4a3de0e --- /dev/null +++ b/packages/backend/src/apps/clickup/actions/create-list/index.js @@ -0,0 +1,135 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create list', + key: 'createList', + description: 'Creates a new list.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.spaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.spaceId', + value: '{parameters.spaceId}', + }, + ], + }, + }, + { + label: 'List Name', + key: 'listName', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'List Info', + key: 'listInfo', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Urgent', value: 1 }, + { label: 'High', value: 2 }, + { label: 'Normal', value: 3 }, + { label: 'Low', value: 4 }, + ], + }, + { + label: 'Due Date', + key: 'dueDate', + type: 'string', + required: false, + description: 'format: integer ', + variables: true, + }, + ], + + async run($) { + const { folderId, listName, listInfo, priority, dueDate } = + $.step.parameters; + + const body = { + name: listName, + content: listInfo, + }; + + if (priority) { + body.priority = priority; + } + + if (dueDate) { + body.due_date = dueDate; + } + + const { data } = await $.http.post(`/v2/folder/${folderId}/list`, body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/clickup/actions/create-task/index.js b/packages/backend/src/apps/clickup/actions/create-task/index.js new file mode 100644 index 0000000..c6f10fd --- /dev/null +++ b/packages/backend/src/apps/clickup/actions/create-task/index.js @@ -0,0 +1,294 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create task', + key: 'createTask', + description: 'Creates a new task.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.spaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.spaceId', + value: '{parameters.spaceId}', + }, + ], + }, + }, + { + label: 'List', + key: 'listId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.folderId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLists', + }, + { + name: 'parameters.folderId', + value: '{parameters.folderId}', + }, + ], + }, + }, + { + label: 'Task Name', + key: 'taskName', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Task Description', + key: 'taskDescription', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Markdown Content', + key: 'markdownContent', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'False', value: 'false' }, + { label: 'True', value: 'true' }, + ], + }, + { + label: 'Assignees', + key: 'assigneeIds', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Assignee', + key: 'assigneeId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.listId'], + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAssignees', + }, + { + name: 'parameters.listId', + value: '{parameters.listId}', + }, + ], + }, + }, + ], + }, + { + label: 'Task Status', + key: 'taskStatus', + type: 'dropdown', + required: false, + dependsOn: ['parameters.listId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listStatuses', + }, + { + name: 'parameters.listId', + value: '{parameters.listId}', + }, + ], + }, + }, + { + label: 'Tags', + key: 'tagIds', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'tag', + key: 'tagId', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTags', + }, + ], + }, + }, + ], + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Urgent', value: 1 }, + { label: 'High', value: 2 }, + { label: 'Normal', value: 3 }, + { label: 'Low', value: 4 }, + ], + }, + { + label: 'Due Date', + key: 'dueDate', + type: 'string', + required: false, + description: 'format: integer ', + variables: true, + }, + { + label: 'Start Date', + key: 'startDate', + type: 'string', + required: false, + description: 'format: integer ', + variables: true, + }, + ], + + async run($) { + const { + listId, + taskName, + taskDescription, + markdownContent, + assigneeIds, + taskStatus, + tagIds, + priority, + dueDate, + startDate, + } = $.step.parameters; + + const tags = tagIds.map((tag) => tag.tagId); + const assignees = assigneeIds.map((assignee) => + Number(assignee.assigneeId) + ); + + const body = { + name: taskName, + }; + + if (assignees.length) { + body.assignees = assignees; + } + + if (taskStatus) { + body.status = taskStatus; + } + + if (tags.length) { + body.tags = tags; + } + + if (priority) { + body.priority = priority; + } + + if (dueDate) { + body.due_date = dueDate; + } + + if (startDate) { + body.start_date = startDate; + } + + if (markdownContent) { + body.markdown_description = taskDescription; + } else { + body.description = taskDescription; + } + + const { data } = await $.http.post(`/v2/list/${listId}/task`, body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/clickup/actions/find-task-by-id/index.js b/packages/backend/src/apps/clickup/actions/find-task-by-id/index.js new file mode 100644 index 0000000..70d8ba9 --- /dev/null +++ b/packages/backend/src/apps/clickup/actions/find-task-by-id/index.js @@ -0,0 +1,82 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find task by id', + key: 'findTaskById', + description: 'Finds a task using id.', + arguments: [ + { + label: 'Task ID', + key: 'taskId', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Use Custom ID', + key: 'useCustomId', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { + label: 'True', + value: true, + }, + { + label: 'False', + value: false, + }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listFieldsWhenUsingCustomId', + }, + { + name: 'parameters.useCustomId', + value: '{parameters.useCustomId}', + }, + ], + }, + }, + { + label: 'Include Subtasks?', + key: 'includeSubtasks', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { + label: 'True', + value: true, + }, + { + label: 'False', + value: false, + }, + ], + }, + ], + + async run($) { + const { taskId, useCustomId, includeSubtasks } = $.step.parameters; + + const params = { + custom_task_ids: useCustomId || false, + include_subtasks: includeSubtasks, + }; + + const { data } = await $.http.get(`/v2/task/${taskId}`, { params }); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/clickup/actions/index.js b/packages/backend/src/apps/clickup/actions/index.js new file mode 100644 index 0000000..caa1fb6 --- /dev/null +++ b/packages/backend/src/apps/clickup/actions/index.js @@ -0,0 +1,6 @@ +import createFolder from './create-folder/index.js'; +import createList from './create-list/index.js'; +import createTask from './create-task/index.js'; +import findTaskById from './find-task-by-id/index.js'; + +export default [createFolder, createList, createTask, findTaskById]; diff --git a/packages/backend/src/apps/clickup/assets/favicon.svg b/packages/backend/src/apps/clickup/assets/favicon.svg new file mode 100644 index 0000000..9894326 --- /dev/null +++ b/packages/backend/src/apps/clickup/assets/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/backend/src/apps/clickup/auth/generate-auth-url.js b/packages/backend/src/apps/clickup/auth/generate-auth-url.js new file mode 100644 index 0000000..0b38be1 --- /dev/null +++ b/packages/backend/src/apps/clickup/auth/generate-auth-url.js @@ -0,0 +1,21 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const state = Math.random().toString(); + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + state, + }); + + const url = `https://app.clickup.com/api?${searchParams.toString()}`; + + await $.auth.set({ + url, + originalState: state, + }); +} diff --git a/packages/backend/src/apps/clickup/auth/index.js b/packages/backend/src/apps/clickup/auth/index.js new file mode 100644 index 0000000..d4ff5f8 --- /dev/null +++ b/packages/backend/src/apps/clickup/auth/index.js @@ -0,0 +1,46 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/clickup/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in ClickUp, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/clickup/auth/is-still-verified.js b/packages/backend/src/apps/clickup/auth/is-still-verified.js new file mode 100644 index 0000000..0896289 --- /dev/null +++ b/packages/backend/src/apps/clickup/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/clickup/auth/verify-credentials.js b/packages/backend/src/apps/clickup/auth/verify-credentials.js new file mode 100644 index 0000000..39302f2 --- /dev/null +++ b/packages/backend/src/apps/clickup/auth/verify-credentials.js @@ -0,0 +1,31 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + if ($.auth.data.originalState !== $.auth.data.state) { + throw new Error(`The 'state' parameter does not match.`); + } + + const { data } = await $.http.post('/v2/oauth/token', { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + const screenName = [currentUser.username, currentUser.email] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/clickup/common/add-auth-header.js b/packages/backend/src/apps/clickup/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/clickup/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/clickup/common/get-current-user.js b/packages/backend/src/apps/clickup/common/get-current-user.js new file mode 100644 index 0000000..d6119e3 --- /dev/null +++ b/packages/backend/src/apps/clickup/common/get-current-user.js @@ -0,0 +1,6 @@ +const getCurrentUser = async ($) => { + const { data } = await $.http.get('/v2/user'); + return data.user; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/clickup/dynamic-data/index.js b/packages/backend/src/apps/clickup/dynamic-data/index.js new file mode 100644 index 0000000..4422c38 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/index.js @@ -0,0 +1,19 @@ +import listAssignees from './list-assignees/index.js'; +import listFolders from './list-folders/index.js'; +import listLists from './list-lists/index.js'; +import listSpaces from './list-spaces/index.js'; +import listStatuses from './list-statuses/index.js'; +import listTags from './list-tags/index.js'; +import listTasks from './list-tasks/index.js'; +import listWorkspaces from './list-workspaces/index.js'; + +export default [ + listAssignees, + listFolders, + listLists, + listSpaces, + listStatuses, + listTags, + listTasks, + listWorkspaces, +]; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-assignees/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-assignees/index.js new file mode 100644 index 0000000..f968130 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-assignees/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List assignees', + key: 'listAssignees', + + async run($) { + const assignees = { + data: [], + }; + const listId = $.step.parameters.listId; + + if (!listId) { + return assignees; + } + + const { data } = await $.http.get(`/v2/list/${listId}/member`); + + if (data.members) { + for (const member of data.members) { + assignees.data.push({ + value: member.id, + name: member.username, + }); + } + } + + return assignees; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-folders/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-folders/index.js new file mode 100644 index 0000000..4294f15 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-folders/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List folders', + key: 'listFolders', + + async run($) { + const folders = { + data: [], + }; + const spaceId = $.step.parameters.spaceId; + + if (!spaceId) { + return folders; + } + + const { data } = await $.http.get(`/v2/space/${spaceId}/folder`); + + if (data.folders) { + for (const folder of data.folders) { + folders.data.push({ + value: folder.id, + name: folder.name, + }); + } + } + + return folders; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-lists/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-lists/index.js new file mode 100644 index 0000000..3bbd308 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-lists/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List lists', + key: 'listLists', + + async run($) { + const lists = { + data: [], + }; + const folderId = $.step.parameters.folderId; + + if (!folderId) { + return lists; + } + + const { data } = await $.http.get(`/v2/folder/${folderId}/list`); + + if (data.lists) { + for (const list of data.lists) { + lists.data.push({ + value: list.id, + name: list.name, + }); + } + } + + return lists; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-spaces/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-spaces/index.js new file mode 100644 index 0000000..59ab319 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-spaces/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List spaces', + key: 'listSpaces', + + async run($) { + const spaces = { + data: [], + }; + const workspaceId = $.step.parameters.workspaceId; + + if (!workspaceId) { + return spaces; + } + + const { data } = await $.http.get(`/v2/team/${workspaceId}/space`); + + if (data.spaces) { + for (const space of data.spaces) { + spaces.data.push({ + value: space.id, + name: space.name, + }); + } + } + + return spaces; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-statuses/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-statuses/index.js new file mode 100644 index 0000000..b0005e9 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-statuses/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List statuses', + key: 'listStatuses', + + async run($) { + const statuses = { + data: [], + }; + const listId = $.step.parameters.listId; + + if (!listId) { + return statuses; + } + + const { data } = await $.http.get(`/v2/list/${listId}`); + + if (data.statuses) { + for (const status of data.statuses) { + statuses.data.push({ + value: status.status, + name: status.status, + }); + } + } + + return statuses; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-tags/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-tags/index.js new file mode 100644 index 0000000..835fef8 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-tags/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List tags', + key: 'listTags', + + async run($) { + const tags = { + data: [], + }; + const spaceId = $.step.parameters.spaceId; + + if (!spaceId) { + return spaceId; + } + + const { data } = await $.http.get(`v2/space/${spaceId}/tag`); + + if (data.tags) { + for (const tag of data.tags) { + tags.data.push({ + value: tag.name, + name: tag.name, + }); + } + } + + return tags; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-tasks/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-tasks/index.js new file mode 100644 index 0000000..6f5b691 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-tasks/index.js @@ -0,0 +1,41 @@ +export default { + name: 'List tasks', + key: 'listTasks', + + async run($) { + const tasks = { + data: [], + }; + const listId = $.step.parameters.listId; + let next = false; + + if (!listId) { + return tasks; + } + + const params = { + order_by: 'created', + reverse: true, + }; + + do { + const { data } = await $.http.get(`/v2/list/${listId}/task`, { params }); + if (data.last_page) { + next = false; + } else { + next = true; + } + + if (data.tasks) { + for (const task of data.tasks) { + tasks.data.push({ + value: task.id, + name: task.name, + }); + } + } + } while (next); + + return tasks; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-data/list-workspaces/index.js b/packages/backend/src/apps/clickup/dynamic-data/list-workspaces/index.js new file mode 100644 index 0000000..3f591b5 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-data/list-workspaces/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List workspaces', + key: 'listWorkspaces', + + async run($) { + const workspaces = { + data: [], + }; + + const { data } = await $.http.get('/v2/team'); + + if (data.teams) { + for (const workspace of data.teams) { + workspaces.data.push({ + value: workspace.id, + name: workspace.name, + }); + } + } + + return workspaces; + }, +}; diff --git a/packages/backend/src/apps/clickup/dynamic-fields/index.js b/packages/backend/src/apps/clickup/dynamic-fields/index.js new file mode 100644 index 0000000..3340a68 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import useCustomId from './use-custom-id/index.js'; + +export default [useCustomId]; diff --git a/packages/backend/src/apps/clickup/dynamic-fields/use-custom-id/index.js b/packages/backend/src/apps/clickup/dynamic-fields/use-custom-id/index.js new file mode 100644 index 0000000..bb1b3e0 --- /dev/null +++ b/packages/backend/src/apps/clickup/dynamic-fields/use-custom-id/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List workspaces when using custom id', + key: 'listFieldsWhenUsingCustomId', + + async run($) { + if ($.step.parameters.useCustomId) { + return [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + ]; + } + }, +}; diff --git a/packages/backend/src/apps/clickup/index.js b/packages/backend/src/apps/clickup/index.js new file mode 100644 index 0000000..327bb0b --- /dev/null +++ b/packages/backend/src/apps/clickup/index.js @@ -0,0 +1,24 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; +import actions from './actions/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'ClickUp', + key: 'clickup', + baseUrl: 'https://clickup.com', + apiBaseUrl: 'https://api.clickup.com/api', + iconUrl: '{BASE_URL}/apps/clickup/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/clickup/connection', + primaryColor: '#FD71AF', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, + actions, + dynamicFields, +}); diff --git a/packages/backend/src/apps/clickup/triggers/index.js b/packages/backend/src/apps/clickup/triggers/index.js new file mode 100644 index 0000000..6d17522 --- /dev/null +++ b/packages/backend/src/apps/clickup/triggers/index.js @@ -0,0 +1,6 @@ +import newFolders from './new-folders/index.js'; +import newLists from './new-lists/index.js'; +import newTasks from './new-tasks/index.js'; +import updatedTask from './updated-task/index.js'; + +export default [newFolders, newLists, newTasks, updatedTask]; diff --git a/packages/backend/src/apps/clickup/triggers/new-folders/index.js b/packages/backend/src/apps/clickup/triggers/new-folders/index.js new file mode 100644 index 0000000..fe31de3 --- /dev/null +++ b/packages/backend/src/apps/clickup/triggers/new-folders/index.js @@ -0,0 +1,105 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New folders', + key: 'newFolder', + type: 'webhook', + description: 'Triggers when a new folder is created.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: $.request.body.folder_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const sampleEventData = { + event: 'folderCreated', + folder_id: '90180382912', + webhook_id: Crypto.randomUUID(), + }; + + const dataItem = { + raw: sampleEventData, + meta: { + internalId: '', + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const { workspaceId, spaceId } = $.step.parameters; + + const payload = { + name: $.flow.id, + endpoint: $.webhookUrl, + events: ['folderCreated'], + }; + + if (spaceId) { + payload.space_id = spaceId; + } + + const { data } = await $.http.post( + `/v2/team/${workspaceId}/webhook`, + payload + ); + + await $.flow.setRemoteWebhookId(data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v2/webhook/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/clickup/triggers/new-lists/index.js b/packages/backend/src/apps/clickup/triggers/new-lists/index.js new file mode 100644 index 0000000..b8552fb --- /dev/null +++ b/packages/backend/src/apps/clickup/triggers/new-lists/index.js @@ -0,0 +1,129 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New lists', + key: 'newLists', + type: 'webhook', + description: 'Triggers when a new list is created.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.spaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.spaceId', + value: '{parameters.spaceId}', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: $.request.body.list_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const sampleEventData = { + event: 'listCreated', + list_id: '901800588812', + webhook_id: Crypto.randomUUID(), + }; + + const dataItem = { + raw: sampleEventData, + meta: { + internalId: sampleEventData.webhook_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const { workspaceId, spaceId, folderId } = $.step.parameters; + + const payload = { + name: $.flow.id, + endpoint: $.webhookUrl, + events: ['listCreated'], + space_id: spaceId, + }; + + if (folderId) { + payload.folder_id = folderId; + } + + const { data } = await $.http.post( + `/v2/team/${workspaceId}/webhook`, + payload + ); + + await $.flow.setRemoteWebhookId(data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v2/webhook/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/clickup/triggers/new-tasks/index.js b/packages/backend/src/apps/clickup/triggers/new-tasks/index.js new file mode 100644 index 0000000..5f8485f --- /dev/null +++ b/packages/backend/src/apps/clickup/triggers/new-tasks/index.js @@ -0,0 +1,186 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New tasks', + key: 'newTasks', + type: 'webhook', + description: 'Triggers when a new task is created.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.spaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.spaceId', + value: '{parameters.spaceId}', + }, + ], + }, + }, + { + label: 'List', + key: 'listId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.folderId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLists', + }, + { + name: 'parameters.folderId', + value: '{parameters.folderId}', + }, + ], + }, + }, + { + label: 'Task', + key: 'taskId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.listId'], + description: + 'Choose an optional task to determine when this flow should be activated. In this scenario, only subtasks will initiate this flow.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTasks', + }, + { + name: 'parameters.listId', + value: '{parameters.listId}', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: $.request.body.task_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const sampleEventData = { + event: 'taskCreated', + task_id: '86enn7pg7', + webhook_id: Crypto.randomUUID(), + history_items: [], + }; + + const dataItem = { + raw: sampleEventData, + meta: { + internalId: sampleEventData.webhook_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const { workspaceId, spaceId, folderId, listId, taskId } = + $.step.parameters; + + const payload = { + name: $.flow.id, + endpoint: $.webhookUrl, + events: ['taskCreated'], + space_id: spaceId, + }; + + if (folderId) { + payload.folder_id = folderId; + } + + if (listId) { + payload.list_id = listId; + } + + if (taskId) { + payload.task_id = taskId; + } + + const { data } = await $.http.post( + `/v2/team/${workspaceId}/webhook`, + payload + ); + + await $.flow.setRemoteWebhookId(data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v2/webhook/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/clickup/triggers/updated-task/index.js b/packages/backend/src/apps/clickup/triggers/updated-task/index.js new file mode 100644 index 0000000..f6dde55 --- /dev/null +++ b/packages/backend/src/apps/clickup/triggers/updated-task/index.js @@ -0,0 +1,172 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Updated task', + key: 'updatedTask', + type: 'webhook', + description: 'Triggers when a task is updated.', + arguments: [ + { + label: 'Workspace', + key: 'workspaceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorkspaces', + }, + ], + }, + }, + { + label: 'Space', + key: 'spaceId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.workspaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpaces', + }, + { + name: 'parameters.workspaceId', + value: '{parameters.workspaceId}', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.spaceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.spaceId', + value: '{parameters.spaceId}', + }, + ], + }, + }, + { + label: 'List', + key: 'listId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.folderId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLists', + }, + { + name: 'parameters.folderId', + value: '{parameters.folderId}', + }, + ], + }, + }, + { + label: 'What Changed?', + key: 'whatChanged', + type: 'dropdown', + required: false, + variables: true, + options: [ + { label: 'Status', value: 'taskStatusUpdated' }, + { label: 'Assignee Added', value: 'taskAssigneeUpdated' }, + { label: 'Priority', value: 'taskPriorityUpdated' }, + { label: 'Tag Added', value: 'taskTagUpdated' }, + ], + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const sampleEventData = { + event: 'taskUpdated', + task_id: '86enn7pg7', + webhook_id: Crypto.randomUUID(), + history_items: [], + }; + + const dataItem = { + raw: sampleEventData, + meta: { + internalId: sampleEventData.webhook_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const { workspaceId, spaceId, folderId, listId, whatChanged } = + $.step.parameters; + + const payload = { + name: $.flow.id, + endpoint: $.webhookUrl, + space_id: spaceId, + }; + + payload.events = [whatChanged || 'taskUpdated']; + + if (folderId) { + payload.folder_id = folderId; + } + + if (listId) { + payload.list_id = listId; + } + + const { data } = await $.http.post( + `/v2/team/${workspaceId}/webhook`, + payload + ); + + await $.flow.setRemoteWebhookId(data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v2/webhook/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/code/actions/index.js b/packages/backend/src/apps/code/actions/index.js new file mode 100644 index 0000000..d2b4207 --- /dev/null +++ b/packages/backend/src/apps/code/actions/index.js @@ -0,0 +1,3 @@ +import runJavascript from './run-javascript/index.js'; + +export default [runJavascript]; diff --git a/packages/backend/src/apps/code/actions/run-javascript/index.js b/packages/backend/src/apps/code/actions/run-javascript/index.js new file mode 100644 index 0000000..a90ab75 --- /dev/null +++ b/packages/backend/src/apps/code/actions/run-javascript/index.js @@ -0,0 +1,84 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Run Javascript', + key: 'runJavascript', + description: + 'Run browser Javascript code. You can not use NodeJS specific features and npm packages.', + arguments: [ + { + label: 'Inputs', + key: 'inputs', + type: 'dynamic', + required: false, + description: + 'To be able to use data from previous steps, you need to expose them as input entries. You can access these input values in your code by using the `inputs` argument.', + value: [ + { + key: '', + value: '', + }, + ], + fields: [ + { + label: 'Key', + key: 'key', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + valueType: 'parse', + }, + ], + }, + { + label: 'Code Snippet', + key: 'codeSnippet', + type: 'code', + required: true, + variables: false, + value: + 'const code = async (inputs) => { \n // E.g. if you have an input called username,\n // you can access its value by calling inputs.username\n // Return value will be used as output of this step.\n\n return true;\n};', + }, + ], + + async run($) { + const { inputs = [], codeSnippet } = $.step.parameters; + + const objectifiedInput = {}; + for (const input of inputs) { + if (input.key) { + objectifiedInput[input.key] = input.value; + } + } + + const ivm = (await import('isolated-vm')).default; + const isolate = new ivm.Isolate({ memoryLimit: 128 }); + + try { + const context = await isolate.createContext(); + await context.global.set( + 'inputs', + new ivm.ExternalCopy(objectifiedInput).copyInto() + ); + + const compiledCodeSnippet = await isolate.compileScript( + `${codeSnippet}; code(inputs);` + ); + const codeFunction = await compiledCodeSnippet.run(context, { + reference: true, + promise: true, + }); + + $.setActionItem({ raw: { output: await codeFunction.copy() } }); + } finally { + isolate.dispose(); + } + }, +}); diff --git a/packages/backend/src/apps/code/assets/favicon.svg b/packages/backend/src/apps/code/assets/favicon.svg new file mode 100644 index 0000000..a3d2016 --- /dev/null +++ b/packages/backend/src/apps/code/assets/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/backend/src/apps/code/index.js b/packages/backend/src/apps/code/index.js new file mode 100644 index 0000000..bc92c1e --- /dev/null +++ b/packages/backend/src/apps/code/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Code', + key: 'code', + baseUrl: '', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/code/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/code/connection', + primaryColor: '#000000', + supportsConnections: false, + actions, +}); diff --git a/packages/backend/src/apps/cryptography/actions/create-hmac/index.js b/packages/backend/src/apps/cryptography/actions/create-hmac/index.js new file mode 100644 index 0000000..e14f0d6 --- /dev/null +++ b/packages/backend/src/apps/cryptography/actions/create-hmac/index.js @@ -0,0 +1,64 @@ +import { createHmac } from 'node:crypto'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create HMAC', + key: 'createHmac', + description: 'Create a Hash-based Message Authentication Code (HMAC) using the specified algorithm, secret key, and message.', + arguments: [ + { + label: 'Algorithm', + key: 'algorithm', + type: 'dropdown', + required: true, + value: 'sha256', + description: 'Specifies the cryptographic hash function to use for HMAC generation.', + options: [ + { label: 'SHA-256', value: 'sha256' }, + ], + variables: true, + }, + { + label: 'Message', + key: 'message', + type: 'string', + required: true, + description: 'The input message to be hashed. This is the value that will be processed to generate the HMAC.', + variables: true, + }, + { + label: 'Secret Key', + key: 'secretKey', + type: 'string', + required: true, + description: 'The secret key used to create the HMAC.', + variables: true, + }, + { + label: 'Output Encoding', + key: 'outputEncoding', + type: 'dropdown', + required: true, + value: 'hex', + description: 'Specifies the encoding format for the HMAC digest output.', + options: [ + { label: 'base64', value: 'base64' }, + { label: 'base64url', value: 'base64url' }, + { label: 'hex', value: 'hex' }, + ], + variables: true, + }, + ], + + async run($) { + const hash = createHmac($.step.parameters.algorithm, $.step.parameters.secretKey) + .update($.step.parameters.message) + .digest($.step.parameters.outputEncoding); + + $.setActionItem({ + raw: { + hash + }, + }); + }, +}); diff --git a/packages/backend/src/apps/cryptography/actions/create-rsa-sha256-signature/index.js b/packages/backend/src/apps/cryptography/actions/create-rsa-sha256-signature/index.js new file mode 100644 index 0000000..446309e --- /dev/null +++ b/packages/backend/src/apps/cryptography/actions/create-rsa-sha256-signature/index.js @@ -0,0 +1,65 @@ +import crypto from 'node:crypto'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create Signature', + key: 'createSignature', + description: 'Create a digital signature using the specified algorithm, secret key, and message.', + arguments: [ + { + label: 'Algorithm', + key: 'algorithm', + type: 'dropdown', + required: true, + value: 'RSA-SHA256', + description: 'Specifies the cryptographic hash function to use for HMAC generation.', + options: [ + { label: 'RSA-SHA256', value: 'RSA-SHA256' }, + ], + variables: true, + }, + { + label: 'Message', + key: 'message', + type: 'string', + required: true, + description: 'The input message to be signed.', + variables: true, + }, + { + label: 'Private Key', + key: 'privateKey', + type: 'string', + required: true, + description: 'The RSA private key in PEM format used for signing.', + variables: true, + }, + { + label: 'Output Encoding', + key: 'outputEncoding', + type: 'dropdown', + required: true, + value: 'hex', + description: 'Specifies the encoding format for the digital signature output. This determines how the generated signature will be represented as a string.', + options: [ + { label: 'base64', value: 'base64' }, + { label: 'base64url', value: 'base64url' }, + { label: 'hex', value: 'hex' }, + ], + variables: true, + }, + ], + + async run($) { + const signer = crypto.createSign($.step.parameters.algorithm); + signer.update($.step.parameters.message); + signer.end(); + const signature = signer.sign($.step.parameters.privateKey, $.step.parameters.outputEncoding); + + $.setActionItem({ + raw: { + signature + }, + }); + }, +}); diff --git a/packages/backend/src/apps/cryptography/actions/index.js b/packages/backend/src/apps/cryptography/actions/index.js new file mode 100644 index 0000000..ab2da71 --- /dev/null +++ b/packages/backend/src/apps/cryptography/actions/index.js @@ -0,0 +1,4 @@ +import createHmac from './create-hmac/index.js'; +import createRsaSha256Signature from './create-rsa-sha256-signature/index.js'; + +export default [createHmac, createRsaSha256Signature]; diff --git a/packages/backend/src/apps/cryptography/assets/favicon.svg b/packages/backend/src/apps/cryptography/assets/favicon.svg new file mode 100644 index 0000000..da52932 --- /dev/null +++ b/packages/backend/src/apps/cryptography/assets/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/backend/src/apps/cryptography/index.js b/packages/backend/src/apps/cryptography/index.js new file mode 100644 index 0000000..c3ab033 --- /dev/null +++ b/packages/backend/src/apps/cryptography/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Cryptography', + key: 'cryptography', + iconUrl: '{BASE_URL}/apps/cryptography/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/cryptography/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#001F52', + actions, +}); diff --git a/packages/backend/src/apps/datastore/actions/get-value/index.js b/packages/backend/src/apps/datastore/actions/get-value/index.js new file mode 100644 index 0000000..e574b57 --- /dev/null +++ b/packages/backend/src/apps/datastore/actions/get-value/index.js @@ -0,0 +1,27 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Get value', + key: 'getValue', + description: 'Get value from the persistent datastore.', + arguments: [ + { + label: 'Key', + key: 'key', + type: 'string', + required: true, + description: 'The key of your value to get.', + variables: true, + }, + ], + + async run($) { + const keyValuePair = await $.datastore.get({ + key: $.step.parameters.key, + }); + + $.setActionItem({ + raw: keyValuePair, + }); + }, +}); diff --git a/packages/backend/src/apps/datastore/actions/index.js b/packages/backend/src/apps/datastore/actions/index.js new file mode 100644 index 0000000..0d2b221 --- /dev/null +++ b/packages/backend/src/apps/datastore/actions/index.js @@ -0,0 +1,4 @@ +import getValue from './get-value/index.js'; +import setValue from './set-value/index.js'; + +export default [getValue, setValue]; diff --git a/packages/backend/src/apps/datastore/actions/set-value/index.js b/packages/backend/src/apps/datastore/actions/set-value/index.js new file mode 100644 index 0000000..0ec9a7e --- /dev/null +++ b/packages/backend/src/apps/datastore/actions/set-value/index.js @@ -0,0 +1,36 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Set value', + key: 'setValue', + description: 'Set value to the persistent datastore.', + arguments: [ + { + label: 'Key', + key: 'key', + type: 'string', + required: true, + description: 'The key of your value to set.', + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + description: 'The value to set.', + variables: true, + }, + ], + + async run($) { + const keyValuePair = await $.datastore.set({ + key: $.step.parameters.key, + value: $.step.parameters.value, + }); + + $.setActionItem({ + raw: keyValuePair, + }); + }, +}); diff --git a/packages/backend/src/apps/datastore/assets/favicon.svg b/packages/backend/src/apps/datastore/assets/favicon.svg new file mode 100644 index 0000000..c45032f --- /dev/null +++ b/packages/backend/src/apps/datastore/assets/favicon.svg @@ -0,0 +1,13 @@ + + + + + + datastore + + + + + + + diff --git a/packages/backend/src/apps/datastore/index.js b/packages/backend/src/apps/datastore/index.js new file mode 100644 index 0000000..0562027 --- /dev/null +++ b/packages/backend/src/apps/datastore/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Datastore', + key: 'datastore', + iconUrl: '{BASE_URL}/apps/datastore/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/datastore/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#001F52', + actions, +}); diff --git a/packages/backend/src/apps/deepl/actions/index.js b/packages/backend/src/apps/deepl/actions/index.js new file mode 100644 index 0000000..f22e6e6 --- /dev/null +++ b/packages/backend/src/apps/deepl/actions/index.js @@ -0,0 +1,3 @@ +import translateText from './translate-text/index.js'; + +export default [translateText]; diff --git a/packages/backend/src/apps/deepl/actions/translate-text/index.js b/packages/backend/src/apps/deepl/actions/translate-text/index.js new file mode 100644 index 0000000..7202d02 --- /dev/null +++ b/packages/backend/src/apps/deepl/actions/translate-text/index.js @@ -0,0 +1,77 @@ +import qs from 'qs'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Translate text', + key: 'translateText', + description: 'Translates text from one language to another.', + arguments: [ + { + label: 'Text', + key: 'text', + type: 'string', + required: true, + description: 'Text to be translated.', + variables: true, + }, + { + label: 'Target Language', + key: 'targetLanguage', + type: 'dropdown', + required: true, + description: 'Language to translate the text to.', + variables: true, + value: '', + options: [ + { label: 'Bulgarian', value: 'BG' }, + { label: 'Chinese (simplified)', value: 'ZH' }, + { label: 'Czech', value: 'CS' }, + { label: 'Danish', value: 'DA' }, + { label: 'Dutch', value: 'NL' }, + { label: 'English', value: 'EN' }, + { label: 'English (American)', value: 'EN-US' }, + { label: 'English (British)', value: 'EN-GB' }, + { label: 'Estonian', value: 'ET' }, + { label: 'Finnish', value: 'FI' }, + { label: 'French', value: 'FR' }, + { label: 'German', value: 'DE' }, + { label: 'Greek', value: 'EL' }, + { label: 'Hungarian', value: 'HU' }, + { label: 'Indonesian', value: 'ID' }, + { label: 'Italian', value: 'IT' }, + { label: 'Japanese', value: 'JA' }, + { label: 'Latvian', value: 'LV' }, + { label: 'Lithuanian', value: 'LT' }, + { label: 'Polish', value: 'PL' }, + { label: 'Portuguese', value: 'PT' }, + { label: 'Portuguese (Brazilian)', value: 'PT-BR' }, + { + label: + 'Portuguese (all Portuguese varieties excluding Brazilian Portuguese)', + value: 'PT-PT', + }, + { label: 'Romanian', value: 'RO' }, + { label: 'Russian', value: 'RU' }, + { label: 'Slovak', value: 'SK' }, + { label: 'Slovenian', value: 'SL' }, + { label: 'Spanish', value: 'ES' }, + { label: 'Swedish', value: 'SV' }, + { label: 'Turkish', value: 'TR' }, + { label: 'Ukrainian', value: 'UK' }, + ], + }, + ], + + async run($) { + const stringifiedBody = qs.stringify({ + text: $.step.parameters.text, + target_lang: $.step.parameters.targetLanguage, + }); + + const response = await $.http.post('/v2/translate', stringifiedBody); + + $.setActionItem({ + raw: response.data, + }); + }, +}); diff --git a/packages/backend/src/apps/deepl/assets/favicon.svg b/packages/backend/src/apps/deepl/assets/favicon.svg new file mode 100644 index 0000000..7b96b43 --- /dev/null +++ b/packages/backend/src/apps/deepl/assets/favicon.svg @@ -0,0 +1,39 @@ + + image/svg+xml + + + + + + + background + + + + Layer 1 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/backend/src/apps/deepl/auth/index.js b/packages/backend/src/apps/deepl/auth/index.js new file mode 100644 index 0000000..0de2ecc --- /dev/null +++ b/packages/backend/src/apps/deepl/auth/index.js @@ -0,0 +1,33 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'authenticationKey', + label: 'Authentication Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'DeepL authentication key of your account.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/deepl/auth/is-still-verified.js b/packages/backend/src/apps/deepl/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/deepl/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/deepl/auth/verify-credentials.js b/packages/backend/src/apps/deepl/auth/verify-credentials.js new file mode 100644 index 0000000..1310f2c --- /dev/null +++ b/packages/backend/src/apps/deepl/auth/verify-credentials.js @@ -0,0 +1,9 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v2/usage'); + + await $.auth.set({ + screenName: $.auth.data.screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/deepl/common/add-auth-header.js b/packages/backend/src/apps/deepl/common/add-auth-header.js new file mode 100644 index 0000000..c1938e8 --- /dev/null +++ b/packages/backend/src/apps/deepl/common/add-auth-header.js @@ -0,0 +1,10 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.authenticationKey) { + const authorizationHeader = `DeepL-Auth-Key ${$.auth.data.authenticationKey}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/deepl/index.js b/packages/backend/src/apps/deepl/index.js new file mode 100644 index 0000000..5ae5c0d --- /dev/null +++ b/packages/backend/src/apps/deepl/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'DeepL', + key: 'deepl', + iconUrl: '{BASE_URL}/apps/deepl/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/deepl/connection', + supportsConnections: true, + baseUrl: 'https://deepl.com', + apiBaseUrl: 'https://api.deepl.com', + primaryColor: '#0d2d45', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/delay/actions/delay-for/index.js b/packages/backend/src/apps/delay/actions/delay-for/index.js new file mode 100644 index 0000000..e50455e --- /dev/null +++ b/packages/backend/src/apps/delay/actions/delay-for/index.js @@ -0,0 +1,56 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Delay for', + key: 'delayFor', + description: + 'Delays the execution of the next action by a specified amount of time.', + arguments: [ + { + label: 'Delay for unit', + key: 'delayForUnit', + type: 'dropdown', + required: true, + value: null, + description: 'Delay for unit, e.g. minutes, hours, days, weeks.', + variables: true, + options: [ + { + label: 'Minutes', + value: 'minutes', + }, + { + label: 'Hours', + value: 'hours', + }, + { + label: 'Days', + value: 'days', + }, + { + label: 'Weeks', + value: 'weeks', + }, + ], + }, + { + label: 'Delay for value', + key: 'delayForValue', + type: 'string', + required: true, + description: 'Delay for value, use a number, e.g. 1, 2, 3.', + variables: true, + }, + ], + + async run($) { + const { delayForUnit, delayForValue } = $.step.parameters; + + const dataItem = { + delayForUnit, + delayForValue, + }; + + $.setActionItem({ raw: dataItem }); + }, +}); diff --git a/packages/backend/src/apps/delay/actions/delay-until/index.js b/packages/backend/src/apps/delay/actions/delay-until/index.js new file mode 100644 index 0000000..4d82b23 --- /dev/null +++ b/packages/backend/src/apps/delay/actions/delay-until/index.js @@ -0,0 +1,28 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Delay until', + key: 'delayUntil', + description: + 'Delays the execution of the next action until a specified date.', + arguments: [ + { + label: 'Delay until (Date)', + key: 'delayUntil', + type: 'string', + required: true, + description: 'Delay until the date. E.g. 2022-12-18', + variables: true, + }, + ], + + async run($) { + const { delayUntil } = $.step.parameters; + + const dataItem = { + delayUntil, + }; + + $.setActionItem({ raw: dataItem }); + }, +}); diff --git a/packages/backend/src/apps/delay/actions/index.js b/packages/backend/src/apps/delay/actions/index.js new file mode 100644 index 0000000..8782fa7 --- /dev/null +++ b/packages/backend/src/apps/delay/actions/index.js @@ -0,0 +1,4 @@ +import delayFor from './delay-for/index.js'; +import delayUntil from './delay-until/index.js'; + +export default [delayFor, delayUntil]; diff --git a/packages/backend/src/apps/delay/assets/favicon.svg b/packages/backend/src/apps/delay/assets/favicon.svg new file mode 100644 index 0000000..af5da4d --- /dev/null +++ b/packages/backend/src/apps/delay/assets/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/backend/src/apps/delay/index.js b/packages/backend/src/apps/delay/index.js new file mode 100644 index 0000000..e89f374 --- /dev/null +++ b/packages/backend/src/apps/delay/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Delay', + key: 'delay', + iconUrl: '{BASE_URL}/apps/delay/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/delay/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#001F52', + actions, +}); diff --git a/packages/backend/src/apps/discord/actions/create-scheduled-event/index.js b/packages/backend/src/apps/discord/actions/create-scheduled-event/index.js new file mode 100644 index 0000000..40a0611 --- /dev/null +++ b/packages/backend/src/apps/discord/actions/create-scheduled-event/index.js @@ -0,0 +1,88 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create a scheduled event', + key: 'createScheduledEvent', + description: 'Creates a scheduled event', + arguments: [ + { + label: 'Type', + key: 'entityType', + type: 'dropdown', + required: true, + variables: true, + options: [ + { label: 'Stage channel', value: 1 }, + { label: 'Voice channel', value: 2 }, + { label: 'External', value: 3 }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listExternalScheduledEventFields', + }, + { + name: 'parameters.entityType', + value: '{parameters.entityType}', + }, + ], + }, + }, + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + variables: true, + }, + { + label: 'Image', + key: 'image', + type: 'string', + required: false, + description: + 'Image as DataURI scheme [data:image/;base64,BASE64_ENCODED__IMAGE_DATA]', + variables: true, + }, + ], + + async run($) { + const data = { + channel_id: $.step.parameters.channel_id, + name: $.step.parameters.name, + privacy_level: 2, + scheduled_start_time: $.step.parameters.scheduledStartTime, + scheduled_end_time: $.step.parameters.scheduledEndTime, + description: $.step.parameters.description, + entity_type: $.step.parameters.entityType, + image: $.step.parameters.image, + }; + + const isExternal = $.step.parameters.entityType === 3; + + if (isExternal) { + data.entity_metadata = { + location: $.step.parameters.location, + }; + + data.channel_id = null; + } + + const response = await $.http?.post( + `/guilds/${$.auth.data.guildId}/scheduled-events`, + data + ); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/discord/actions/index.js b/packages/backend/src/apps/discord/actions/index.js new file mode 100644 index 0000000..598e7b2 --- /dev/null +++ b/packages/backend/src/apps/discord/actions/index.js @@ -0,0 +1,4 @@ +import sendMessageToChannel from './send-message-to-channel/index.js'; +import createScheduledEvent from './create-scheduled-event/index.js'; + +export default [sendMessageToChannel, createScheduledEvent]; diff --git a/packages/backend/src/apps/discord/actions/send-message-to-channel/index.js b/packages/backend/src/apps/discord/actions/send-message-to-channel/index.js new file mode 100644 index 0000000..332ed9b --- /dev/null +++ b/packages/backend/src/apps/discord/actions/send-message-to-channel/index.js @@ -0,0 +1,48 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send a message to channel', + key: 'sendMessageToChannel', + description: 'Sends a message to a specific channel you specify.', + arguments: [ + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + required: true, + description: 'Pick a channel to send the message to.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listChannels', + }, + ], + }, + }, + { + label: 'Message text', + key: 'message', + type: 'string', + required: true, + description: 'The content of your new message.', + variables: true, + }, + ], + + async run($) { + const data = { + content: $.step.parameters.message, + }; + + const response = await $.http?.post( + `/channels/${$.step.parameters.channel}/messages`, + data + ); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/discord/assets/favicon.svg b/packages/backend/src/apps/discord/assets/favicon.svg new file mode 100644 index 0000000..0483a9d --- /dev/null +++ b/packages/backend/src/apps/discord/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/discord/auth/generate-auth-url.js b/packages/backend/src/apps/discord/auth/generate-auth-url.js new file mode 100644 index 0000000..60ac0b8 --- /dev/null +++ b/packages/backend/src/apps/discord/auth/generate-auth-url.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'url'; +import scopes from '../common/scopes.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.consumerKey, + redirect_uri: callbackUrl, + response_type: 'code', + permissions: '2146958591', + scope: scopes.join(' '), + }); + + const url = `${$.app.apiBaseUrl}/oauth2/authorize?${searchParams.toString()}`; + + await $.auth.set({ url }); +} diff --git a/packages/backend/src/apps/discord/auth/index.js b/packages/backend/src/apps/discord/auth/index.js new file mode 100644 index 0000000..62579e4 --- /dev/null +++ b/packages/backend/src/apps/discord/auth/index.js @@ -0,0 +1,61 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/discord/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Discord OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/discord#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'Consumer Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/discord#consumer-key', + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'Consumer Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/discord#consumer-secret', + clickToCopy: false, + }, + { + key: 'botToken', + label: 'Bot token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/discord#bot-token', + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/discord/auth/is-still-verified.js b/packages/backend/src/apps/discord/auth/is-still-verified.js new file mode 100644 index 0000000..6274890 --- /dev/null +++ b/packages/backend/src/apps/discord/auth/is-still-verified.js @@ -0,0 +1,9 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + await getCurrentUser($); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/discord/auth/verify-credentials.js b/packages/backend/src/apps/discord/auth/verify-credentials.js new file mode 100644 index 0000000..57555c8 --- /dev/null +++ b/packages/backend/src/apps/discord/auth/verify-credentials.js @@ -0,0 +1,55 @@ +import { URLSearchParams } from 'url'; +import scopes from '../common/scopes.js'; +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + + const params = new URLSearchParams({ + client_id: $.auth.data.consumerKey, + redirect_uri: callbackUrl, + response_type: 'code', + scope: scopes.join(' '), + client_secret: $.auth.data.consumerSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + }); + + const { data: verifiedCredentials } = await $.http.post( + '/oauth2/token', + params.toString() + ); + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + scope: scope, + token_type: tokenType, + guild: { id: guildId, name: guildName }, + } = verifiedCredentials; + + await $.auth.set({ + accessToken, + refreshToken, + expiresIn, + scope, + tokenType, + }); + + const user = await getCurrentUser($); + + await $.auth.set({ + userId: user.id, + screenName: user.username, + email: user.email, + guildId, + guildName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/discord/common/add-auth-header.js b/packages/backend/src/apps/discord/common/add-auth-header.js new file mode 100644 index 0000000..d9f5b10 --- /dev/null +++ b/packages/backend/src/apps/discord/common/add-auth-header.js @@ -0,0 +1,10 @@ +const addAuthHeader = ($, requestConfig) => { + const { tokenType, botToken } = $.auth.data; + if (tokenType && botToken) { + requestConfig.headers.Authorization = `Bot ${botToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/discord/common/get-current-user.js b/packages/backend/src/apps/discord/common/get-current-user.js new file mode 100644 index 0000000..57ab474 --- /dev/null +++ b/packages/backend/src/apps/discord/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/users/@me'); + const currentUser = response.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/discord/common/scopes.js b/packages/backend/src/apps/discord/common/scopes.js new file mode 100644 index 0000000..c924ca8 --- /dev/null +++ b/packages/backend/src/apps/discord/common/scopes.js @@ -0,0 +1,3 @@ +const scopes = ['bot', 'identify']; + +export default scopes; diff --git a/packages/backend/src/apps/discord/dynamic-data/index.js b/packages/backend/src/apps/discord/dynamic-data/index.js new file mode 100644 index 0000000..d6a7cec --- /dev/null +++ b/packages/backend/src/apps/discord/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listChannels from './list-channels/index.js'; +import listVoiceChannels from './list-voice-channels/index.js'; + +export default [listChannels, listVoiceChannels]; diff --git a/packages/backend/src/apps/discord/dynamic-data/list-channels/index.js b/packages/backend/src/apps/discord/dynamic-data/list-channels/index.js new file mode 100644 index 0000000..52fc719 --- /dev/null +++ b/packages/backend/src/apps/discord/dynamic-data/list-channels/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List channels', + key: 'listChannels', + + async run($) { + const channels = { + data: [], + error: null, + }; + + const response = await $.http.get( + `/guilds/${$.auth.data.guildId}/channels` + ); + + channels.data = response.data + .filter((channel) => { + // filter in text channels and announcement channels only + return channel.type === 0 || channel.type === 5; + }) + .map((channel) => { + return { + value: channel.id, + name: channel.name, + }; + }); + + return channels; + }, +}; diff --git a/packages/backend/src/apps/discord/dynamic-data/list-voice-channels/index.js b/packages/backend/src/apps/discord/dynamic-data/list-voice-channels/index.js new file mode 100644 index 0000000..975e5fa --- /dev/null +++ b/packages/backend/src/apps/discord/dynamic-data/list-voice-channels/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List voice channels', + key: 'listVoiceChannels', + + async run($) { + const channels = { + data: [], + error: null, + }; + + const response = await $.http.get( + `/guilds/${$.auth.data.guildId}/channels` + ); + + channels.data = response.data + .filter((channel) => { + // filter in voice and stage channels only + return channel.type === 2 || channel.type === 13; + }) + .map((channel) => { + return { + value: channel.id, + name: channel.name, + }; + }); + + return channels; + }, +}; diff --git a/packages/backend/src/apps/discord/dynamic-fields/index.js b/packages/backend/src/apps/discord/dynamic-fields/index.js new file mode 100644 index 0000000..889acb3 --- /dev/null +++ b/packages/backend/src/apps/discord/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listExternalScheduledEventFields from './list-external-scheduled-event-fields/index.js'; + +export default [listExternalScheduledEventFields]; diff --git a/packages/backend/src/apps/discord/dynamic-fields/list-external-scheduled-event-fields/index.js b/packages/backend/src/apps/discord/dynamic-fields/list-external-scheduled-event-fields/index.js new file mode 100644 index 0000000..dbe66bf --- /dev/null +++ b/packages/backend/src/apps/discord/dynamic-fields/list-external-scheduled-event-fields/index.js @@ -0,0 +1,87 @@ +export default { + name: 'List external scheduled event fields', + key: 'listExternalScheduledEventFields', + + async run($) { + const isExternal = $.step.parameters.entityType === 3; + + if (isExternal) { + return [ + { + label: 'Location', + key: 'location', + type: 'string', + required: true, + description: + 'The location of the event (1-100 characters). This will be omitted if type is NOT EXTERNAL', + variables: true, + }, + { + label: 'Start-Time', + key: 'scheduledStartTime', + type: 'string', + required: true, + description: 'The time the event will start [ISO8601]', + variables: true, + }, + { + label: 'End-Time', + key: 'scheduledEndTime', + type: 'string', + required: true, + description: + 'The time the event will end [ISO8601]. This will be omitted if type is NOT EXTERNAL', + variables: true, + }, + ]; + } + + return [ + { + label: 'Channel', + key: 'channel_id', + type: 'dropdown', + required: true, + description: + 'Pick a voice or stage channel to link the event to. This will be omitted if type is EXTERNAL', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceChannels', + }, + ], + }, + }, + { + label: 'Location', + key: 'location', + type: 'string', + required: false, + description: + 'The location of the event (1-100 characters). This will be omitted if type is NOT EXTERNAL', + variables: true, + }, + { + label: 'Start-Time', + key: 'scheduledStartTime', + type: 'string', + required: true, + description: 'The time the event will start [ISO8601]', + variables: true, + }, + { + label: 'End-Time', + key: 'scheduledEndTime', + type: 'string', + required: false, + description: + 'The time the event will end [ISO8601]. This will be omitted if type is NOT EXTERNAL', + variables: true, + }, + ]; + }, +}; diff --git a/packages/backend/src/apps/discord/index.js b/packages/backend/src/apps/discord/index.js new file mode 100644 index 0000000..35255fc --- /dev/null +++ b/packages/backend/src/apps/discord/index.js @@ -0,0 +1,24 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import dynamicData from './dynamic-data/index.js'; +import actions from './actions/index.js'; +import triggers from './triggers/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'Discord', + key: 'discord', + iconUrl: '{BASE_URL}/apps/discord/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/discord/connection', + supportsConnections: true, + baseUrl: 'https://discord.com', + apiBaseUrl: 'https://discord.com/api', + primaryColor: '#5865f2', + beforeRequest: [addAuthHeader], + auth, + dynamicData, + dynamicFields, + triggers, + actions, +}); diff --git a/packages/backend/src/apps/discord/triggers/index.js b/packages/backend/src/apps/discord/triggers/index.js new file mode 100644 index 0000000..d6d1738 --- /dev/null +++ b/packages/backend/src/apps/discord/triggers/index.js @@ -0,0 +1 @@ +export default []; diff --git a/packages/backend/src/apps/disqus/assets/favicon.svg b/packages/backend/src/apps/disqus/assets/favicon.svg new file mode 100644 index 0000000..66ffeb3 --- /dev/null +++ b/packages/backend/src/apps/disqus/assets/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/packages/backend/src/apps/disqus/auth/generate-auth-url.js b/packages/backend/src/apps/disqus/auth/generate-auth-url.js new file mode 100644 index 0000000..f94a525 --- /dev/null +++ b/packages/backend/src/apps/disqus/auth/generate-auth-url.js @@ -0,0 +1,21 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.apiKey, + scope: authScope.join(','), + response_type: 'code', + redirect_uri: redirectUri, + }); + + const url = `https://disqus.com/api/oauth/2.0/authorize/?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/disqus/auth/index.js b/packages/backend/src/apps/disqus/auth/index.js new file mode 100644 index 0000000..fb84d80 --- /dev/null +++ b/packages/backend/src/apps/disqus/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/disqus/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Disqus, enter the URL above.', + clickToCopy: true, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'apiSecret', + label: 'API Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/disqus/auth/is-still-verified.js b/packages/backend/src/apps/disqus/auth/is-still-verified.js new file mode 100644 index 0000000..c42b4a9 --- /dev/null +++ b/packages/backend/src/apps/disqus/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.response.username; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/disqus/auth/refresh-token.js b/packages/backend/src/apps/disqus/auth/refresh-token.js new file mode 100644 index 0000000..c813898 --- /dev/null +++ b/packages/backend/src/apps/disqus/auth/refresh-token.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'node:url'; +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: $.auth.data.apiKey, + client_secret: $.auth.data.apiSecret, + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + `https://disqus.com/api/oauth/2.0/access_token/`, + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + scope: authScope.join(','), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/disqus/auth/verify-credentials.js b/packages/backend/src/apps/disqus/auth/verify-credentials.js new file mode 100644 index 0000000..a3c3eb3 --- /dev/null +++ b/packages/backend/src/apps/disqus/auth/verify-credentials.js @@ -0,0 +1,34 @@ +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: $.auth.data.apiKey, + client_secret: $.auth.data.apiSecret, + redirect_uri: redirectUri, + code: $.auth.data.code, + }); + + const { data } = await $.http.post( + `https://disqus.com/api/oauth/2.0/access_token/`, + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + apiKey: $.auth.data.apiKey, + apiSecret: $.auth.data.apiSecret, + scope: $.auth.data.scope, + userId: data.user_id, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + screenName: data.username, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/disqus/common/add-auth-header.js b/packages/backend/src/apps/disqus/common/add-auth-header.js new file mode 100644 index 0000000..45e5c68 --- /dev/null +++ b/packages/backend/src/apps/disqus/common/add-auth-header.js @@ -0,0 +1,15 @@ +import { URLSearchParams } from 'url'; + +const addAuthHeader = ($, requestConfig) => { + const params = new URLSearchParams({ + access_token: $.auth.data.accessToken, + api_key: $.auth.data.apiKey, + api_secret: $.auth.data.apiSecret, + }); + + requestConfig.params = params; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/disqus/common/auth-scope.js b/packages/backend/src/apps/disqus/common/auth-scope.js new file mode 100644 index 0000000..97f3eb8 --- /dev/null +++ b/packages/backend/src/apps/disqus/common/auth-scope.js @@ -0,0 +1,3 @@ +const authScope = ['read', 'write', 'admin', 'email']; + +export default authScope; diff --git a/packages/backend/src/apps/disqus/common/get-current-user.js b/packages/backend/src/apps/disqus/common/get-current-user.js new file mode 100644 index 0000000..63b0c78 --- /dev/null +++ b/packages/backend/src/apps/disqus/common/get-current-user.js @@ -0,0 +1,10 @@ +const getCurrentUser = async ($) => { + try { + const { data: currentUser } = await $.http.get('/3.0/users/details.json'); + return currentUser; + } catch (error) { + throw new Error('You are not authenticated.'); + } +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/disqus/dynamic-data/index.js b/packages/backend/src/apps/disqus/dynamic-data/index.js new file mode 100644 index 0000000..3198aee --- /dev/null +++ b/packages/backend/src/apps/disqus/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listForums from './list-forums/index.js'; + +export default [listForums]; diff --git a/packages/backend/src/apps/disqus/dynamic-data/list-forums/index.js b/packages/backend/src/apps/disqus/dynamic-data/list-forums/index.js new file mode 100644 index 0000000..bb4298e --- /dev/null +++ b/packages/backend/src/apps/disqus/dynamic-data/list-forums/index.js @@ -0,0 +1,36 @@ +export default { + name: 'List forums', + key: 'listForums', + + async run($) { + const forums = { + data: [], + }; + + const params = { + limit: 100, + order: 'desc', + cursor: undefined, + }; + + let more; + do { + const { data } = await $.http.get('/3.0/users/listForums.json', { + params, + }); + params.cursor = data.cursor.next; + more = data.cursor.hasNext; + + if (data.response?.length) { + for (const forum of data.response) { + forums.data.push({ + value: forum.id, + name: forum.id, + }); + } + } + } while (more); + + return forums; + }, +}; diff --git a/packages/backend/src/apps/disqus/index.js b/packages/backend/src/apps/disqus/index.js new file mode 100644 index 0000000..fd0af22 --- /dev/null +++ b/packages/backend/src/apps/disqus/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import dynamicData from './dynamic-data/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Disqus', + key: 'disqus', + baseUrl: 'https://disqus.com', + apiBaseUrl: 'https://disqus.com/api', + iconUrl: '{BASE_URL}/apps/disqus/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/disqus/connection', + primaryColor: '#2E9FFF', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + dynamicData, + triggers, +}); diff --git a/packages/backend/src/apps/disqus/triggers/index.js b/packages/backend/src/apps/disqus/triggers/index.js new file mode 100644 index 0000000..f530825 --- /dev/null +++ b/packages/backend/src/apps/disqus/triggers/index.js @@ -0,0 +1,4 @@ +import newComments from './new-comments/index.js'; +import newFlaggedComments from './new-flagged-comments/index.js'; + +export default [newComments, newFlaggedComments]; diff --git a/packages/backend/src/apps/disqus/triggers/new-comments/index.js b/packages/backend/src/apps/disqus/triggers/new-comments/index.js new file mode 100644 index 0000000..119c8cf --- /dev/null +++ b/packages/backend/src/apps/disqus/triggers/new-comments/index.js @@ -0,0 +1,92 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { URLSearchParams } from 'url'; + +export default defineTrigger({ + name: 'New comments', + key: 'newComments', + pollInterval: 15, + description: 'Triggers when a new comment is posted in a forum using Disqus.', + arguments: [ + { + label: 'Post Types', + key: 'postTypes', + type: 'dynamic', + required: false, + description: + 'Which posts should be considered for inclusion in the trigger?', + fields: [ + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Unapproved Posts', value: 'unapproved' }, + { label: 'Approved Posts', value: 'approved' }, + { label: 'Spam Posts', value: 'spam' }, + { label: 'Deleted Posts', value: 'deleted' }, + { label: 'Flagged Posts', value: 'flagged' }, + { label: 'Highlighted Posts', value: 'highlighted' }, + ], + }, + ], + }, + { + label: 'Forum', + key: 'forumId', + type: 'dropdown', + required: true, + description: 'Select the forum where you want comments to be triggered.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listForums', + }, + ], + }, + }, + ], + + async run($) { + const forumId = $.step.parameters.forumId; + const postTypes = $.step.parameters.postTypes; + const formattedCommentTypes = postTypes + .filter((type) => type.type !== '') + .map((type) => type.type); + + const params = new URLSearchParams({ + limit: '100', + forum: forumId, + }); + + if (formattedCommentTypes.length) { + formattedCommentTypes.forEach((type) => params.append('include', type)); + } + + let more; + do { + const { data } = await $.http.get( + `/3.0/posts/list.json?${params.toString()}` + ); + params.set('cursor', data.cursor.next); + more = data.cursor.hasNext; + + if (data.response?.length) { + for (const comment of data.response) { + $.pushTriggerItem({ + raw: comment, + meta: { + internalId: comment.id, + }, + }); + } + } + } while (more); + }, +}); diff --git a/packages/backend/src/apps/disqus/triggers/new-flagged-comments/index.js b/packages/backend/src/apps/disqus/triggers/new-flagged-comments/index.js new file mode 100644 index 0000000..2cd87dd --- /dev/null +++ b/packages/backend/src/apps/disqus/triggers/new-flagged-comments/index.js @@ -0,0 +1,60 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { URLSearchParams } from 'url'; + +export default defineTrigger({ + name: 'New flagged comments', + key: 'newFlaggedComments', + pollInterval: 15, + description: 'Triggers when a Disqus comment is marked with a flag', + arguments: [ + { + label: 'Forum', + key: 'forumId', + type: 'dropdown', + required: true, + description: 'Select the forum where you want comments to be triggered.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listForums', + }, + ], + }, + }, + ], + + async run($) { + const forumId = $.step.parameters.forumId; + const isFlaggedFilter = 5; + + const params = new URLSearchParams({ + limit: 100, + forum: forumId, + filters: [isFlaggedFilter], + }); + + let more; + do { + const { data } = await $.http.get( + `/3.0/posts/list.json?${params.toString()}` + ); + params.set('cursor', data.cursor.next); + more = data.cursor.hasNext; + + if (data.response?.length) { + for (const comment of data.response) { + $.pushTriggerItem({ + raw: comment, + meta: { + internalId: comment.id, + }, + }); + } + } + } while (more); + }, +}); diff --git a/packages/backend/src/apps/dropbox/actions/create-folder/index.js b/packages/backend/src/apps/dropbox/actions/create-folder/index.js new file mode 100644 index 0000000..8309f94 --- /dev/null +++ b/packages/backend/src/apps/dropbox/actions/create-folder/index.js @@ -0,0 +1,40 @@ +import path from 'node:path'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create folder', + key: 'createFolder', + description: + 'Create a new folder with the given parent folder and folder name', + arguments: [ + { + label: 'Folder', + key: 'parentFolder', + type: 'string', + required: true, + description: + 'Enter the parent folder path, like /TextFiles/ or /Documents/Taxes/', + variables: true, + }, + { + label: 'Folder Name', + key: 'folderName', + type: 'string', + required: true, + description: 'Enter the name for the new folder', + variables: true, + }, + ], + + async run($) { + const parentFolder = $.step.parameters.parentFolder; + const folderName = $.step.parameters.folderName; + const folderPath = path.join(parentFolder, folderName); + + const response = await $.http.post('/2/files/create_folder_v2', { + path: folderPath, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/dropbox/actions/index.js b/packages/backend/src/apps/dropbox/actions/index.js new file mode 100644 index 0000000..c0b1917 --- /dev/null +++ b/packages/backend/src/apps/dropbox/actions/index.js @@ -0,0 +1,4 @@ +import createFolder from './create-folder/index.js'; +import renameFile from './rename-file/index.js'; + +export default [createFolder, renameFile]; diff --git a/packages/backend/src/apps/dropbox/actions/rename-file/index.js b/packages/backend/src/apps/dropbox/actions/rename-file/index.js new file mode 100644 index 0000000..789b3f3 --- /dev/null +++ b/packages/backend/src/apps/dropbox/actions/rename-file/index.js @@ -0,0 +1,45 @@ +import path from 'node:path'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Rename file', + key: 'renameFile', + description: 'Rename a file with the given file path and new name', + arguments: [ + { + label: 'File Path', + key: 'filePath', + type: 'string', + required: true, + description: 'Write the full path to the file such as /Folder1/File.pdf', + variables: true, + }, + { + label: 'New Name', + key: 'newName', + type: 'string', + required: true, + description: + "Enter the new name for the file (without the extension, e.g., '.pdf')", + variables: true, + }, + ], + + async run($) { + const filePath = $.step.parameters.filePath; + const newName = $.step.parameters.newName; + const fileObject = path.parse(filePath); + const newPath = path.format({ + dir: fileObject.dir, + ext: fileObject.ext, + name: newName, + }); + + const response = await $.http.post('/2/files/move_v2', { + from_path: filePath, + to_path: newPath, + }); + + $.setActionItem({ raw: response.data.metadata }); + }, +}); diff --git a/packages/backend/src/apps/dropbox/assets/favicon.svg b/packages/backend/src/apps/dropbox/assets/favicon.svg new file mode 100644 index 0000000..59f3862 --- /dev/null +++ b/packages/backend/src/apps/dropbox/assets/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/backend/src/apps/dropbox/auth/generate-auth-url.js b/packages/backend/src/apps/dropbox/auth/generate-auth-url.js new file mode 100644 index 0000000..1aa78f6 --- /dev/null +++ b/packages/backend/src/apps/dropbox/auth/generate-auth-url.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'url'; +import scopes from '../common/scopes.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: callbackUrl, + response_type: 'code', + scope: scopes.join(' '), + token_access_type: 'offline', + }); + + const url = `${$.app.baseUrl}/oauth2/authorize?${searchParams.toString()}`; + + await $.auth.set({ url }); +} diff --git a/packages/backend/src/apps/dropbox/auth/index.js b/packages/backend/src/apps/dropbox/auth/index.js new file mode 100644 index 0000000..a037f9e --- /dev/null +++ b/packages/backend/src/apps/dropbox/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/dropbox/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Dropbox OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'App Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'App Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/dropbox/auth/is-still-verified.js b/packages/backend/src/apps/dropbox/auth/is-still-verified.js new file mode 100644 index 0000000..b65bc23 --- /dev/null +++ b/packages/backend/src/apps/dropbox/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentAccount from '../common/get-current-account.js'; + +const isStillVerified = async ($) => { + const account = await getCurrentAccount($); + return !!account; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/dropbox/auth/refresh-token.js b/packages/backend/src/apps/dropbox/auth/refresh-token.js new file mode 100644 index 0000000..1f34cde --- /dev/null +++ b/packages/backend/src/apps/dropbox/auth/refresh-token.js @@ -0,0 +1,36 @@ +import { Buffer } from 'node:buffer'; + +const refreshToken = async ($) => { + const params = { + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }; + + const basicAuthToken = Buffer.from( + `${$.auth.data.clientId}:${$.auth.data.clientSecret}` + ).toString('base64'); + + const { data } = await $.http.post('oauth2/token', null, { + params, + headers: { + Authorization: `Basic ${basicAuthToken}`, + }, + additionalProperties: { + skipAddingAuthHeader: true, + }, + }); + + const { + access_token: accessToken, + expires_in: expiresIn, + token_type: tokenType, + } = data; + + await $.auth.set({ + accessToken, + expiresIn, + tokenType, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/dropbox/auth/verify-credentials.js b/packages/backend/src/apps/dropbox/auth/verify-credentials.js new file mode 100644 index 0000000..22097a1 --- /dev/null +++ b/packages/backend/src/apps/dropbox/auth/verify-credentials.js @@ -0,0 +1,78 @@ +import getCurrentAccount from '../common/get-current-account.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const redirectUrl = oauthRedirectUrlField.value; + + const params = { + client_id: $.auth.data.clientId, + redirect_uri: redirectUrl, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + }; + + const { data: verifiedCredentials } = await $.http.post( + '/oauth2/token', + null, + { params } + ); + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + scope: scope, + token_type: tokenType, + account_id: accountId, + team_id: teamId, + id_token: idToken, + uid, + } = verifiedCredentials; + + await $.auth.set({ + accessToken, + refreshToken, + expiresIn, + scope, + tokenType, + accountId, + teamId, + idToken, + uid, + }); + + const account = await getCurrentAccount($); + + await $.auth.set({ + accountId: account.account_id, + name: { + givenName: account.name.given_name, + surname: account.name.surname, + familiarName: account.name.familiar_name, + displayName: account.name.display_name, + abbreviatedName: account.name.abbreviated_name, + }, + email: account.email, + emailVerified: account.email_verified, + disabled: account.disabled, + country: account.country, + locale: account.locale, + referralLink: account.referral_link, + isPaired: account.is_paired, + accountType: { + '.tag': account.account_type['.tag'], + }, + rootInfo: { + '.tag': account.root_info['.tag'], + rootNamespaceId: account.root_info.root_namespace_id, + homeNamespaceId: account.root_info.home_namespace_id, + }, + screenName: `${account.name.display_name} - ${account.email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/dropbox/common/add-auth-header.js b/packages/backend/src/apps/dropbox/common/add-auth-header.js new file mode 100644 index 0000000..1030633 --- /dev/null +++ b/packages/backend/src/apps/dropbox/common/add-auth-header.js @@ -0,0 +1,14 @@ +const addAuthHeader = ($, requestConfig) => { + requestConfig.headers['Content-Type'] = 'application/json'; + + if ( + !requestConfig.additionalProperties?.skipAddingAuthHeader && + $.auth.data?.accessToken + ) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/dropbox/common/get-current-account.js b/packages/backend/src/apps/dropbox/common/get-current-account.js new file mode 100644 index 0000000..26786dd --- /dev/null +++ b/packages/backend/src/apps/dropbox/common/get-current-account.js @@ -0,0 +1,6 @@ +const getCurrentAccount = async ($) => { + const response = await $.http.post('/2/users/get_current_account', null); + return response.data; +}; + +export default getCurrentAccount; diff --git a/packages/backend/src/apps/dropbox/common/scopes.js b/packages/backend/src/apps/dropbox/common/scopes.js new file mode 100644 index 0000000..b257d7c --- /dev/null +++ b/packages/backend/src/apps/dropbox/common/scopes.js @@ -0,0 +1,8 @@ +const scopes = [ + 'account_info.read', + 'files.metadata.read', + 'files.content.write', + 'files.content.read', +]; + +export default scopes; diff --git a/packages/backend/src/apps/dropbox/index.js b/packages/backend/src/apps/dropbox/index.js new file mode 100644 index 0000000..c0d167d --- /dev/null +++ b/packages/backend/src/apps/dropbox/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Dropbox', + key: 'dropbox', + iconUrl: '{BASE_URL}/apps/dropbox/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/dropbox/connection', + supportsConnections: true, + baseUrl: 'https://dropbox.com', + apiBaseUrl: 'https://api.dropboxapi.com', + primaryColor: '#0061ff', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/filter/actions/continue/index.js b/packages/backend/src/apps/filter/actions/continue/index.js new file mode 100644 index 0000000..5724028 --- /dev/null +++ b/packages/backend/src/apps/filter/actions/continue/index.js @@ -0,0 +1,88 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const isEqual = (a, b) => a === b; +const isNotEqual = (a, b) => !isEqual(a, b); +const isGreaterThan = (a, b) => Number(a) > Number(b); +const isLessThan = (a, b) => Number(a) < Number(b); +const isGreaterThanOrEqual = (a, b) => Number(a) >= Number(b); +const isLessThanOrEqual = (a, b) => Number(a) <= Number(b); +const contains = (a, b) => a.includes(b); +const doesNotContain = (a, b) => !contains(a, b); + +const shouldContinue = (orGroups) => { + let atLeastOneGroupMatches = false; + + for (const group of orGroups) { + let groupMatches = true; + + for (const condition of group.and) { + const conditionMatches = operate( + condition.operator, + condition.key, + condition.value + ); + + if (!conditionMatches) { + groupMatches = false; + + break; + } + } + + if (groupMatches) { + atLeastOneGroupMatches = true; + + break; + } + } + + return atLeastOneGroupMatches; +}; + +const operators = { + equal: isEqual, + not_equal: isNotEqual, + greater_than: isGreaterThan, + less_than: isLessThan, + greater_than_or_equal: isGreaterThanOrEqual, + less_than_or_equal: isLessThanOrEqual, + contains: contains, + not_contains: doesNotContain, +}; + +const operate = (operation, a, b) => { + return operators[operation](a, b); +}; + +export default defineAction({ + name: 'Continue if conditions match', + key: 'continueIfMatches', + description: 'Let the execution continue if the conditions match', + arguments: [], + + async run($) { + const orGroups = $.step.parameters.or; + + const matchingGroups = orGroups.reduce((groups, group) => { + const matchingConditions = group.and.filter((condition) => + operate(condition.operator, condition.key, condition.value) + ); + + if (matchingConditions.length) { + return groups.concat([{ and: matchingConditions }]); + } + + return groups; + }, []); + + if (!shouldContinue(orGroups)) { + $.execution.exit(); + } + + $.setActionItem({ + raw: { + or: matchingGroups, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/filter/actions/index.js b/packages/backend/src/apps/filter/actions/index.js new file mode 100644 index 0000000..0390f47 --- /dev/null +++ b/packages/backend/src/apps/filter/actions/index.js @@ -0,0 +1,3 @@ +import continueIfMatches from './continue/index.js'; + +export default [continueIfMatches]; diff --git a/packages/backend/src/apps/filter/assets/favicon.svg b/packages/backend/src/apps/filter/assets/favicon.svg new file mode 100644 index 0000000..77d3ebe --- /dev/null +++ b/packages/backend/src/apps/filter/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/filter/index.js b/packages/backend/src/apps/filter/index.js new file mode 100644 index 0000000..9854e13 --- /dev/null +++ b/packages/backend/src/apps/filter/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Filter', + key: 'filter', + iconUrl: '{BASE_URL}/apps/filter/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/filter/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#001F52', + actions, +}); diff --git a/packages/backend/src/apps/flickr/assets/favicon.svg b/packages/backend/src/apps/flickr/assets/favicon.svg new file mode 100644 index 0000000..f8499a7 --- /dev/null +++ b/packages/backend/src/apps/flickr/assets/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/backend/src/apps/flickr/auth/generate-auth-url.js b/packages/backend/src/apps/flickr/auth/generate-auth-url.js new file mode 100644 index 0000000..4de36c0 --- /dev/null +++ b/packages/backend/src/apps/flickr/auth/generate-auth-url.js @@ -0,0 +1,20 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + const requestPath = '/oauth/request_token'; + const data = { oauth_callback: callbackUrl }; + + const response = await $.http.post(requestPath, data); + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + url: `${$.app.apiBaseUrl}/oauth/authorize?oauth_token=${responseData.oauth_token}&perms=delete`, + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + }); +} diff --git a/packages/backend/src/apps/flickr/auth/index.js b/packages/backend/src/apps/flickr/auth/index.js new file mode 100644 index 0000000..f06e6db --- /dev/null +++ b/packages/backend/src/apps/flickr/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/flickr/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Flickr OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/flickr#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'Consumer Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/flickr#consumer-key', + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'Consumer Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/flickr#consumer-secret', + clickToCopy: false, + }, + ], + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/flickr/auth/is-still-verified.js b/packages/backend/src/apps/flickr/auth/is-still-verified.js new file mode 100644 index 0000000..3c69582 --- /dev/null +++ b/packages/backend/src/apps/flickr/auth/is-still-verified.js @@ -0,0 +1,11 @@ +const isStillVerified = async ($) => { + const params = { + method: 'flickr.test.login', + format: 'json', + nojsoncallback: 1, + }; + const response = await $.http.get('/rest', { params }); + return !!response.data.user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/flickr/auth/verify-credentials.js b/packages/backend/src/apps/flickr/auth/verify-credentials.js new file mode 100644 index 0000000..1d3905d --- /dev/null +++ b/packages/backend/src/apps/flickr/auth/verify-credentials.js @@ -0,0 +1,21 @@ +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + const response = await $.http.post( + `/oauth/access_token?oauth_verifier=${$.auth.data.oauth_verifier}&oauth_token=${$.auth.data.accessToken}`, + null + ); + + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + consumerKey: $.auth.data.consumerKey, + consumerSecret: $.auth.data.consumerSecret, + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + userId: responseData.user_nsid, + screenName: responseData.fullname, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/flickr/common/add-auth-header.js b/packages/backend/src/apps/flickr/common/add-auth-header.js new file mode 100644 index 0000000..01c2601 --- /dev/null +++ b/packages/backend/src/apps/flickr/common/add-auth-header.js @@ -0,0 +1,33 @@ +import oauthClient from './oauth-client.js'; + +const addAuthHeader = ($, requestConfig) => { + const { url, method, data, params } = requestConfig; + + const token = { + key: $.auth.data?.accessToken, + secret: $.auth.data?.accessSecret, + }; + + const requestData = { + url: `${requestConfig.baseURL}${url}`, + method, + }; + + if (url === '/oauth/request_token') { + requestData.data = data; + } + + if (method === 'get') { + requestData.data = params; + } + + const authHeader = oauthClient($).toHeader( + oauthClient($).authorize(requestData, token) + ); + + requestConfig.headers.Authorization = authHeader.Authorization; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/flickr/common/oauth-client.js b/packages/backend/src/apps/flickr/common/oauth-client.js new file mode 100644 index 0000000..d89c488 --- /dev/null +++ b/packages/backend/src/apps/flickr/common/oauth-client.js @@ -0,0 +1,22 @@ +import crypto from 'crypto'; +import OAuth from 'oauth-1.0a'; + +const oauthClient = ($) => { + const consumerData = { + key: $.auth.data.consumerKey, + secret: $.auth.data.consumerSecret, + }; + + return new OAuth({ + consumer: consumerData, + signature_method: 'HMAC-SHA1', + hash_function(base_string, key) { + return crypto + .createHmac('sha1', key) + .update(base_string) + .digest('base64'); + }, + }); +}; + +export default oauthClient; diff --git a/packages/backend/src/apps/flickr/dynamic-data/index.js b/packages/backend/src/apps/flickr/dynamic-data/index.js new file mode 100644 index 0000000..cd0d4c3 --- /dev/null +++ b/packages/backend/src/apps/flickr/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listAlbums from './list-albums/index.js'; + +export default [listAlbums]; diff --git a/packages/backend/src/apps/flickr/dynamic-data/list-albums/index.js b/packages/backend/src/apps/flickr/dynamic-data/list-albums/index.js new file mode 100644 index 0000000..55f0b9c --- /dev/null +++ b/packages/backend/src/apps/flickr/dynamic-data/list-albums/index.js @@ -0,0 +1,41 @@ +export default { + name: 'List albums', + key: 'listAlbums', + + async run($) { + const params = { + page: 1, + per_page: 500, + user_id: $.auth.data.userId, + method: 'flickr.photosets.getList', + format: 'json', + nojsoncallback: 1, + }; + + let response = await $.http.get('/rest', { params }); + + const aggregatedResponse = { + data: [...response.data.photosets.photoset], + }; + + while (response.data.photosets.page < response.data.photosets.pages) { + response = await $.http.get('/rest', { + params: { + ...params, + page: response.data.photosets.page, + }, + }); + + aggregatedResponse.data.push(...response.data.photosets.photoset); + } + + aggregatedResponse.data = aggregatedResponse.data.map((photoset) => { + return { + value: photoset.id, + name: photoset.title._content, + }; + }); + + return aggregatedResponse; + }, +}; diff --git a/packages/backend/src/apps/flickr/index.js b/packages/backend/src/apps/flickr/index.js new file mode 100644 index 0000000..adea955 --- /dev/null +++ b/packages/backend/src/apps/flickr/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Flickr', + key: 'flickr', + iconUrl: '{BASE_URL}/apps/flickr/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/flickr/connection', + docUrl: 'https://automatisch.io/docs/flickr', + primaryColor: '#000000', + supportsConnections: true, + baseUrl: 'https://www.flickr.com/', + apiBaseUrl: 'https://www.flickr.com/services', + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/flickr/triggers/index.js b/packages/backend/src/apps/flickr/triggers/index.js new file mode 100644 index 0000000..2c97892 --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/index.js @@ -0,0 +1,6 @@ +import newAlbums from './new-albums/index.js'; +import newFavoritePhotos from './new-favorite-photos/index.js'; +import newPhotos from './new-photos/index.js'; +import newPhotosInAlbums from './new-photos-in-album/index.js'; + +export default [newAlbums, newFavoritePhotos, newPhotos, newPhotosInAlbums]; diff --git a/packages/backend/src/apps/flickr/triggers/new-albums/index.js b/packages/backend/src/apps/flickr/triggers/new-albums/index.js new file mode 100644 index 0000000..ff7bca1 --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-albums/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newAlbums from './new-albums.js'; + +export default defineTrigger({ + name: 'New albums', + pollInterval: 15, + key: 'newAlbums', + description: 'Triggers when you create a new album.', + + async run($) { + await newAlbums($); + }, +}); diff --git a/packages/backend/src/apps/flickr/triggers/new-albums/new-albums.js b/packages/backend/src/apps/flickr/triggers/new-albums/new-albums.js new file mode 100644 index 0000000..b449094 --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-albums/new-albums.js @@ -0,0 +1,53 @@ +const extraFields = [ + 'license', + 'date_upload', + 'date_taken', + 'owner_name', + 'icon_server', + 'original_format', + 'last_update', + 'geo', + 'tags', + 'machine_tags', + 'o_dims', + 'views', + 'media', + 'path_alias', + 'url_sq', + 'url_t', + 'url_s', + 'url_m', + 'url_o', +].join(','); + +const newAlbums = async ($) => { + let page = 1; + let pages = 1; + + do { + const params = { + page, + per_page: 500, + user_id: $.auth.data.userId, + extras: extraFields, + method: 'flickr.photosets.getList', + format: 'json', + nojsoncallback: 1, + }; + const response = await $.http.get('/rest', { params }); + const photosets = response.data.photosets; + page = photosets.page + 1; + pages = photosets.pages; + + for (const photoset of photosets.photoset) { + $.pushTriggerItem({ + raw: photoset, + meta: { + internalId: photoset.id, + }, + }); + } + } while (page <= pages); +}; + +export default newAlbums; diff --git a/packages/backend/src/apps/flickr/triggers/new-favorite-photos/index.js b/packages/backend/src/apps/flickr/triggers/new-favorite-photos/index.js new file mode 100644 index 0000000..6e80d2b --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-favorite-photos/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newFavoritePhotos from './new-favorite-photos.js'; + +export default defineTrigger({ + name: 'New favorite photos', + pollInterval: 15, + key: 'newFavoritePhotos', + description: 'Triggers when you favorite a photo.', + + async run($) { + await newFavoritePhotos($); + }, +}); diff --git a/packages/backend/src/apps/flickr/triggers/new-favorite-photos/new-favorite-photos.js b/packages/backend/src/apps/flickr/triggers/new-favorite-photos/new-favorite-photos.js new file mode 100644 index 0000000..5a06649 --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-favorite-photos/new-favorite-photos.js @@ -0,0 +1,59 @@ +const extraFields = [ + 'description', + 'license', + 'date_upload', + 'date_taken', + 'owner_name', + 'icon_server', + 'original_format', + 'last_update', + 'geo', + 'tags', + 'machine_tags', + 'o_dims', + 'views', + 'media', + 'path_alias', + 'url_sq', + 'url_t', + 'url_s', + 'url_q', + 'url_m', + 'url_n', + 'url_z', + 'url_c', + 'url_l', + 'url_o', +].join(','); + +const newPhotos = async ($) => { + let page = 1; + let pages = 1; + + do { + const params = { + page, + per_page: 500, + user_id: $.auth.data.userId, + extras: extraFields, + method: 'flickr.favorites.getList', + format: 'json', + nojsoncallback: 1, + }; + const response = await $.http.get('/rest', { params }); + const photos = response.data.photos; + page = photos.page + 1; + pages = photos.pages; + + for (const photo of photos.photo) { + $.pushTriggerItem({ + raw: photo, + meta: { + internalId: photo.date_faved, + }, + }); + } + } while (page <= pages); +}; + +export default newPhotos; diff --git a/packages/backend/src/apps/flickr/triggers/new-photos-in-album/index.js b/packages/backend/src/apps/flickr/triggers/new-photos-in-album/index.js new file mode 100644 index 0000000..d61261b --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-photos-in-album/index.js @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newPhotosInAlbum from './new-photos-in-album.js'; + +export default defineTrigger({ + name: 'New photos in album', + pollInterval: 15, + key: 'newPhotosInAlbum', + description: 'Triggers when you add a new photo in an album.', + arguments: [ + { + label: 'Album', + key: 'album', + type: 'dropdown', + required: true, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAlbums', + }, + ], + }, + }, + ], + + async run($) { + await newPhotosInAlbum($); + }, +}); diff --git a/packages/backend/src/apps/flickr/triggers/new-photos-in-album/new-photos-in-album.js b/packages/backend/src/apps/flickr/triggers/new-photos-in-album/new-photos-in-album.js new file mode 100644 index 0000000..aa681dd --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-photos-in-album/new-photos-in-album.js @@ -0,0 +1,54 @@ +const extraFields = [ + 'license', + 'date_upload', + 'date_taken', + 'owner_name', + 'icon_server', + 'original_format', + 'last_update', + 'geo', + 'tags', + 'machine_tags', + 'o_dims', + 'views', + 'media', + 'path_alias', + 'url_sq', + 'url_t', + 'url_s', + 'url_m', + 'url_o', +].join(','); + +const newPhotosInAlbum = async ($) => { + let page = 1; + let pages = 1; + + do { + const params = { + page, + per_page: 11, + user_id: $.auth.data.userId, + extras: extraFields, + photoset_id: $.step.parameters.album, + method: 'flickr.photosets.getPhotos', + format: 'json', + nojsoncallback: 1, + }; + const response = await $.http.get('/rest', { params }); + const photoset = response.data.photoset; + page = photoset.page + 1; + pages = photoset.pages; + + for (const photo of photoset.photo) { + $.pushTriggerItem({ + raw: photo, + meta: { + internalId: photo.id, + }, + }); + } + } while (page <= pages); +}; + +export default newPhotosInAlbum; diff --git a/packages/backend/src/apps/flickr/triggers/new-photos/index.js b/packages/backend/src/apps/flickr/triggers/new-photos/index.js new file mode 100644 index 0000000..f88a7b9 --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-photos/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newPhotos from './new-photos.js'; + +export default defineTrigger({ + name: 'New photos', + pollInterval: 15, + key: 'newPhotos', + description: 'Triggers when you add a new photo.', + + async run($) { + await newPhotos($); + }, +}); diff --git a/packages/backend/src/apps/flickr/triggers/new-photos/new-photos.js b/packages/backend/src/apps/flickr/triggers/new-photos/new-photos.js new file mode 100644 index 0000000..feef50e --- /dev/null +++ b/packages/backend/src/apps/flickr/triggers/new-photos/new-photos.js @@ -0,0 +1,59 @@ +const extraFields = [ + 'description', + 'license', + 'date_upload', + 'date_taken', + 'owner_name', + 'icon_server', + 'original_format', + 'last_update', + 'geo', + 'tags', + 'machine_tags', + 'o_dims', + 'views', + 'media', + 'path_alias', + 'url_sq', + 'url_t', + 'url_s', + 'url_q', + 'url_m', + 'url_n', + 'url_z', + 'url_c', + 'url_l', + 'url_o', +].join(','); + +const newPhotos = async ($) => { + let page = 1; + let pages = 1; + + do { + const params = { + page, + per_page: 500, + user_id: $.auth.data.userId, + extras: extraFields, + method: 'flickr.photos.search', + format: 'json', + nojsoncallback: 1, + }; + const response = await $.http.get('/rest', { params }); + const photos = response.data.photos; + page = photos.page + 1; + pages = photos.pages; + + for (const photo of photos.photo) { + $.pushTriggerItem({ + raw: photo, + meta: { + internalId: photo.id, + }, + }); + } + } while (page <= pages); +}; + +export default newPhotos; diff --git a/packages/backend/src/apps/flowers-software/assets/favicon.svg b/packages/backend/src/apps/flowers-software/assets/favicon.svg new file mode 100644 index 0000000..55b8ed6 --- /dev/null +++ b/packages/backend/src/apps/flowers-software/assets/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/flowers-software/auth/index.js b/packages/backend/src/apps/flowers-software/auth/index.js new file mode 100644 index 0000000..0472451 --- /dev/null +++ b/packages/backend/src/apps/flowers-software/auth/index.js @@ -0,0 +1,43 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'username', + label: 'Username', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'password', + label: 'Password', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/flowers-software/auth/is-still-verified.js b/packages/backend/src/apps/flowers-software/auth/is-still-verified.js new file mode 100644 index 0000000..270d415 --- /dev/null +++ b/packages/backend/src/apps/flowers-software/auth/is-still-verified.js @@ -0,0 +1,9 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/flowers-software/auth/verify-credentials.js b/packages/backend/src/apps/flowers-software/auth/verify-credentials.js new file mode 100644 index 0000000..33c709b --- /dev/null +++ b/packages/backend/src/apps/flowers-software/auth/verify-credentials.js @@ -0,0 +1,19 @@ +import getWebhooks from '../common/get-webhooks.js'; + +const verifyCredentials = async ($) => { + const response = await getWebhooks($); + const successful = Array.isArray(response.data); + + if (!successful) { + throw new Error('Failed while authorizing!'); + } + + await $.auth.set({ + screenName: $.auth.data.username, + username: $.auth.data.username, + password: $.auth.data.password, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/flowers-software/common/add-auth-header.js b/packages/backend/src/apps/flowers-software/common/add-auth-header.js new file mode 100644 index 0000000..23e6460 --- /dev/null +++ b/packages/backend/src/apps/flowers-software/common/add-auth-header.js @@ -0,0 +1,16 @@ +const addAuthHeader = ($, requestConfig) => { + const { data } = $.auth; + + if (data?.username && data.password && data.apiKey) { + requestConfig.headers['x-api-key'] = data.apiKey; + + requestConfig.auth = { + username: data.username, + password: data.password, + }; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/flowers-software/common/get-webhooks.js b/packages/backend/src/apps/flowers-software/common/get-webhooks.js new file mode 100644 index 0000000..70c06fe --- /dev/null +++ b/packages/backend/src/apps/flowers-software/common/get-webhooks.js @@ -0,0 +1,3 @@ +export default async function getWebhooks($) { + return await $.http.get('/v2/public/api/webhooks'); +} diff --git a/packages/backend/src/apps/flowers-software/common/webhook-filters.js b/packages/backend/src/apps/flowers-software/common/webhook-filters.js new file mode 100644 index 0000000..d8a22d1 --- /dev/null +++ b/packages/backend/src/apps/flowers-software/common/webhook-filters.js @@ -0,0 +1,488 @@ +const webhookFilters = [ + { + label: "Contact Company Created", + value: "CONTACT_COMPANY_CREATED" + }, + { + label: "Contact Company Deleted", + value: "CONTACT_COMPANY_DELETED" + }, + { + label: "Contact Company Updated", + value: "CONTACT_COMPANY_UPDATED" + }, + { + label: "Contact Created", + value: "CONTACT_CREATED" + }, + { + label: "Contact Deleted", + value: "CONTACT_DELETED" + }, + { + label: "Contact Updated", + value: "CONTACT_UPDATED" + }, + { + label: "Customer Created", + value: "CUSTOMER_CREATED" + }, + { + label: "Customer Updated", + value: "CUSTOMER_UPDATED" + }, + { + label: "Document Deleted", + value: "DOCUMENT_DELETED" + }, + { + label: "Document Downloaded", + value: "DOCUMENT_DOWNLOADED" + }, + { + label: "Document Saved", + value: "DOCUMENT_SAVED" + }, + { + label: "Document Updated", + value: "DOCUMENT_UPDATED" + }, + { + label: "Flow Archived", + value: "FLOW_ARCHIVED" + }, + { + label: "Flow Created", + value: "FLOW_CREATED" + }, + { + label: "Flow Object Automation Action Created", + value: "FLOW_OBJECT_AUTOMATION_ACTION_CREATED" + }, + { + label: "Flow Object Automation Action Deleted", + value: "FLOW_OBJECT_AUTOMATION_ACTION_DELETED" + }, + { + label: "Flow Object Automation Created", + value: "FLOW_OBJECT_AUTOMATION_CREATED" + }, + { + label: "Flow Object Automation Deleted", + value: "FLOW_OBJECT_AUTOMATION_DELETED" + }, + { + label: "Flow Object Automation Updated", + value: "FLOW_OBJECT_AUTOMATION_UPDATED" + }, + { + label: "Flow Object Automation Webdav Created", + value: "FLOW_OBJECT_AUTOMATION_WEBDAV_CREATED" + }, + { + label: "Flow Object Automation Webdav Deleted", + value: "FLOW_OBJECT_AUTOMATION_WEBDAV_DELETED" + }, + { + label: "Flow Object Automation Webdav Updated", + value: "FLOW_OBJECT_AUTOMATION_WEBDAV_UPDATED" + }, + { + label: "Flow Object Created", + value: "FLOW_OBJECT_CREATED" + }, + { + label: "Flow Object Deleted", + value: "FLOW_OBJECT_DELETED" + }, + { + label: "Flow Object Document Added", + value: "FLOW_OBJECT_DOCUMENT_ADDED" + }, + { + label: "Flow Object Document Removed", + value: "FLOW_OBJECT_DOCUMENT_REMOVED" + }, + { + label: "Flow Object Resource Created", + value: "FLOW_OBJECT_RESOURCE_CREATED" + }, + { + label: "Flow Object Resource Deleted", + value: "FLOW_OBJECT_RESOURCE_DELETED" + }, + { + label: "Flow Object Resource Updated", + value: "FLOW_OBJECT_RESOURCE_UPDATED" + }, + { + label: "Flow Object Task Condition Created", + value: "FLOW_OBJECT_TASK_CONDITION_CREATED" + }, + { + label: "Flow Object Task Condition Deleted", + value: "FLOW_OBJECT_TASK_CONDITION_DELETED" + }, + { + label: "Flow Object Task Condition Updated", + value: "FLOW_OBJECT_TASK_CONDITION_UPDATED" + }, + { + label: "Flow Object Task Created", + value: "FLOW_OBJECT_TASK_CREATED" + }, + { + label: "Flow Object Task Deleted", + value: "FLOW_OBJECT_TASK_DELETED" + }, + { + label: "Flow Object Task Updated", + value: "FLOW_OBJECT_TASK_UPDATED" + }, + { + label: "Flow Object Updated", + value: "FLOW_OBJECT_UPDATED" + }, + { + label: "Flow Objects Connection Created", + value: "FLOW_OBJECTS_CONNECTION_CREATED" + }, + { + label: "Flow Objects Connection Deleted", + value: "FLOW_OBJECTS_CONNECTION_DELETED" + }, + { + label: "Flow Objects Connection Updated", + value: "FLOW_OBJECTS_CONNECTION_UPDATED" + }, + { + label: "Flow Objects External Connection Created", + value: "FLOW_OBJECTS_EXTERNAL_CONNECTION_CREATED" + }, + { + label: "Flow Objects External Connection Deleted", + value: "FLOW_OBJECTS_EXTERNAL_CONNECTION_DELETED" + }, + { + label: "Flow Objects External Connection Updated", + value: "FLOW_OBJECTS_EXTERNAL_CONNECTION_UPDATED" + }, + { + label: "Flow Objects External Connections Group Created", + value: "FLOW_OBJECTS_EXTERNAL_CONNECTIONS_GROUP_CREATED" + }, + { + label: "Flow Objects External Connections Group Deleted", + value: "FLOW_OBJECTS_EXTERNAL_CONNECTIONS_GROUP_DELETED" + }, + { + label: "Flow Objects External Connections Group Updated", + value: "FLOW_OBJECTS_EXTERNAL_CONNECTIONS_GROUP_UPDATED" + }, + { + label: "Flow Unarchived", + value: "FLOW_UNARCHIVED" + }, + { + label: "Flow Updated", + value: "FLOW_UPDATED" + }, + { + label: "Note Created", + value: "NOTE_CREATED" + }, + { + label: "Note Deleted", + value: "NOTE_DELETED" + }, + { + label: "Note Updated", + value: "NOTE_UPDATED" + }, + { + label: "Team Created", + value: "TEAM_CREATED" + }, + { + label: "Team Deleted", + value: "TEAM_DELETED" + }, + { + label: "Team Updated", + value: "TEAM_UPDATED" + }, + { + label: "User Added To Team", + value: "USER_ADDED_TO_TEAM" + }, + { + label: "User Added To Teamleads", + value: "USER_ADDED_TO_TEAMLEADS" + }, + { + label: "User Archived", + value: "USER_ARCHIVED" + }, + { + label: "User Changed Password", + value: "USER_CHANGED_PASSWORD" + }, + { + label: "User Created", + value: "USER_CREATED" + }, + { + label: "User Forgot Password", + value: "USER_FORGOT_PASSWORD" + }, + { + label: "User Invited", + value: "USER_INVITED" + }, + { + label: "User Logged In", + value: "USER_LOGGED_IN" + }, + { + label: "User Notification Settings Changed", + value: "USER_NOTIFICATION_SETTINGS_CHANGED" + }, + { + label: "User Profile Updated", + value: "USER_PROFILE_UPDATED" + }, + { + label: "User Removed From Team", + value: "USER_REMOVED_FROM_TEAM" + }, + { + label: "User Removed From Teamleads", + value: "USER_REMOVED_FROM_TEAMLEADS" + }, + { + label: "User Unarchived", + value: "USER_UNARCHIVED" + }, + { + label: "Workflow Archived", + value: "WORKFLOW_ARCHIVED" + }, + { + label: "Workflow Completed", + value: "WORKFLOW_COMPLETED" + }, + { + label: "Workflow Created", + value: "WORKFLOW_CREATED" + }, + { + label: "Workflow Creation Failed", + value: "WORKFLOW_CREATION_FAILED" + }, + { + label: "Workflow Object Automation Api Get Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_API_GET_COMPLETED" + }, + { + label: "Workflow Object Automation Api Get Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_API_GET_FAILED" + }, + { + label: "Workflow Object Automation Api Post Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_API_POST_COMPLETED" + }, + { + label: "Workflow Object Automation Api Post Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_API_POST_FAILED" + }, + { + label: "Workflow Object Automation Datev Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_DATEV_COMPLETED" + }, + { + label: "Workflow Object Automation Datev Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_DATEV_FAILED" + }, + { + label: "Workflow Object Automation Email Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_EMAIL_COMPLETED" + }, + { + label: "Workflow Object Automation Email Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_EMAIL_FAILED" + }, + { + label: "Workflow Object Automation Lexoffice Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_LEXOFFICE_COMPLETED" + }, + { + label: "Workflow Object Automation Lexoffice Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_LEXOFFICE_FAILED" + }, + { + label: "Workflow Object Automation Rejected", + value: "WORKFLOW_OBJECT_AUTOMATION_REJECTED" + }, + { + label: "Workflow Object Automation Retried", + value: "WORKFLOW_OBJECT_AUTOMATION_RETRIED" + }, + { + label: "Workflow Object Automation Sevdesk Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_SEVDESK_COMPLETED" + }, + { + label: "Workflow Object Automation Sevdesk Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_SEVDESK_FAILED" + }, + { + label: "Workflow Object Automation Stamp Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_STAMP_COMPLETED" + }, + { + label: "Workflow Object Automation Stamp Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_STAMP_FAILED" + }, + { + label: "Workflow Object Automation Task Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_TASK_COMPLETED" + }, + { + label: "Workflow Object Automation Task Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_TASK_FAILED" + }, + { + label: "Workflow Object Automation Template Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_TEMPLATE_COMPLETED" + }, + { + label: "Workflow Object Automation Template Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_TEMPLATE_FAILED" + }, + { + label: "Workflow Object Automation Webdav Document Uploaded", + value: "WORKFLOW_OBJECT_AUTOMATION_WEBDAV_DOCUMENT_UPLOADED" + }, + { + label: "Workflow Object Automation Zapier Completed", + value: "WORKFLOW_OBJECT_AUTOMATION_ZAPIER_COMPLETED" + }, + { + label: "Workflow Object Automation Zapier Failed", + value: "WORKFLOW_OBJECT_AUTOMATION_ZAPIER_FAILED" + }, + { + label: "Workflow Object Combination Task Group Created", + value: "WORKFLOW_OBJECT_COMBINATION_TASK_GROUP_CREATED" + }, + { + label: "Workflow Object Combination Task Group Deleted", + value: "WORKFLOW_OBJECT_COMBINATION_TASK_GROUP_DELETED" + }, + { + label: "Workflow Object Completed Automations Finished", + value: "WORKFLOW_OBJECT_COMPLETED_AUTOMATIONS_FINISHED" + }, + { + label: "Workflow Object Completed", + value: "WORKFLOW_OBJECT_COMPLETED" + }, + { + label: "Workflow Object Created", + value: "WORKFLOW_OBJECT_CREATED" + }, + { + label: "Workflow Object Document Added", + value: "WORKFLOW_OBJECT_DOCUMENT_ADDED" + }, + { + label: "Workflow Object Document Lock Added", + value: "WORKFLOW_OBJECT_DOCUMENT_LOCK_ADDED" + }, + { + label: "Workflow Object Document Lock Deleted", + value: "WORKFLOW_OBJECT_DOCUMENT_LOCK_DELETED" + }, + { + label: "Workflow Object Document Removed", + value: "WORKFLOW_OBJECT_DOCUMENT_REMOVED" + }, + { + label: "Workflow Object Email Added", + value: "WORKFLOW_OBJECT_EMAIL_ADDED" + }, + { + label: "Workflow Object Email Removed", + value: "WORKFLOW_OBJECT_EMAIL_REMOVED" + }, + { + label: "Workflow Object External User Created", + value: "WORKFLOW_OBJECT_EXTERNAL_USER_CREATED" + }, + { + label: "Workflow Object External User Deleted", + value: "WORKFLOW_OBJECT_EXTERNAL_USER_DELETED" + }, + { + label: "Workflow Object Note Added", + value: "WORKFLOW_OBJECT_NOTE_ADDED" + }, + { + label: "Workflow Object Note Removed", + value: "WORKFLOW_OBJECT_NOTE_REMOVED" + }, + { + label: "Workflow Object Resource Created", + value: "WORKFLOW_OBJECT_RESOURCE_CREATED" + }, + { + label: "Workflow Object Snapshot Created", + value: "WORKFLOW_OBJECT_SNAPSHOT_CREATED" + }, + { + label: "Workflow Object Task Condition Created", + value: "WORKFLOW_OBJECT_TASK_CONDITION_CREATED" + }, + { + label: "Workflow Object Task Created", + value: "WORKFLOW_OBJECT_TASK_CREATED" + }, + { + label: "Workflow Object Task Deleted", + value: "WORKFLOW_OBJECT_TASK_DELETED" + }, + { + label: "Workflow Object Task Snapshot Created", + value: "WORKFLOW_OBJECT_TASK_SNAPSHOT_CREATED" + }, + { + label: "Workflow Object Task Updated", + value: "WORKFLOW_OBJECT_TASK_UPDATED" + }, + { + label: "Workflow Object Updated", + value: "WORKFLOW_OBJECT_UPDATED" + }, + { + label: "Workflow Objects Connection Created", + value: "WORKFLOW_OBJECTS_CONNECTION_CREATED" + }, + { + label: "Workflow Objects External Connection Created", + value: "WORKFLOW_OBJECTS_EXTERNAL_CONNECTION_CREATED" + }, + { + label: "Workflow Objects External Connection Group Created", + value: "WORKFLOW_OBJECTS_EXTERNAL_CONNECTION_GROUP_CREATED" + }, + { + label: "Workflow Unarchived", + value: "WORKFLOW_UNARCHIVED" + }, + { + label: "Workflow Updated", + value: "WORKFLOW_UPDATED" + } +]; + +export default webhookFilters; \ No newline at end of file diff --git a/packages/backend/src/apps/flowers-software/index.js b/packages/backend/src/apps/flowers-software/index.js new file mode 100644 index 0000000..c2dd05a --- /dev/null +++ b/packages/backend/src/apps/flowers-software/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Flowers Software', + key: 'flowers-software', + iconUrl: '{BASE_URL}/apps/flowers-software/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/flowers-software/connection', + supportsConnections: true, + baseUrl: 'https://flowers-software.com', + apiBaseUrl: 'https://webapp.flowers-software.com/api', + primaryColor: '#02AFC7', + beforeRequest: [addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/flowers-software/triggers/index.js b/packages/backend/src/apps/flowers-software/triggers/index.js new file mode 100644 index 0000000..0c60d46 --- /dev/null +++ b/packages/backend/src/apps/flowers-software/triggers/index.js @@ -0,0 +1,3 @@ +import newActivity from './new-activity/index.js'; + +export default [newActivity]; diff --git a/packages/backend/src/apps/flowers-software/triggers/new-activity/index.js b/packages/backend/src/apps/flowers-software/triggers/new-activity/index.js new file mode 100644 index 0000000..a0c975e --- /dev/null +++ b/packages/backend/src/apps/flowers-software/triggers/new-activity/index.js @@ -0,0 +1,63 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; +import webhookFilters from '../../common/webhook-filters.js'; + +export default defineTrigger({ + name: 'New activity', + key: 'newActivity', + type: 'webhook', + description: 'Triggers when a new activity occurs.', + arguments: [ + { + label: 'Activity type', + key: 'filters', + type: 'dropdown', + required: true, + description: 'Pick an activity type to receive events for.', + variables: false, + options: webhookFilters, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const payload = { + name: $.flow.id, + type: 'POST', + url: $.webhookUrl, + filters: [$.step.parameters.filters], + }; + + const { data } = await $.http.post(`/v2/public/api/webhooks`, payload); + + await $.flow.setRemoteWebhookId(data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v2/public/api/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/formatter/actions/date-time/index.js b/packages/backend/src/apps/formatter/actions/date-time/index.js new file mode 100644 index 0000000..88fbecb --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/date-time/index.js @@ -0,0 +1,58 @@ +import defineAction from '../../../../helpers/define-action.js'; +import formatDateTime from './transformers/format-date-time.js'; +import getCurrentTimestamp from './transformers/get-current-timestamp.js'; + +const transformers = { + formatDateTime, + getCurrentTimestamp, +}; + +export default defineAction({ + name: 'Date / Time', + key: 'date-time', + description: 'Perform date and time related transformations on your data.', + arguments: [ + { + label: 'Transform', + key: 'transform', + type: 'dropdown', + required: true, + variables: true, + options: [ + { + label: 'Get current timestamp', + value: 'getCurrentTimestamp', + }, + { + label: 'Format Date / Time', + value: 'formatDateTime', + }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listTransformOptions', + }, + { + name: 'parameters.transform', + value: '{parameters.transform}', + }, + ], + }, + }, + ], + + async run($) { + const transformerName = $.step.parameters.transform; + const output = transformers[transformerName]($); + + $.setActionItem({ + raw: { + output, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/formatter/actions/date-time/transformers/format-date-time.js b/packages/backend/src/apps/formatter/actions/date-time/transformers/format-date-time.js new file mode 100644 index 0000000..cc93ae7 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/date-time/transformers/format-date-time.js @@ -0,0 +1,35 @@ +import { DateTime } from 'luxon'; + +const formatDateTime = ($) => { + const input = $.step.parameters.input; + + const fromFormat = $.step.parameters.fromFormat; + const fromTimezone = $.step.parameters.fromTimezone; + let inputDateTime; + + if (fromFormat === 'X') { + inputDateTime = DateTime.fromSeconds(Number(input), fromFormat, { + zone: fromTimezone, + setZone: true, + }); + } else if (fromFormat === 'x') { + inputDateTime = DateTime.fromMillis(Number(input), fromFormat, { + zone: fromTimezone, + setZone: true, + }); + } else { + inputDateTime = DateTime.fromFormat(input, fromFormat, { + zone: fromTimezone, + setZone: true, + }); + } + + const toFormat = $.step.parameters.toFormat; + const toTimezone = $.step.parameters.toTimezone; + + const outputDateTime = inputDateTime.setZone(toTimezone).toFormat(toFormat); + + return outputDateTime; +}; + +export default formatDateTime; diff --git a/packages/backend/src/apps/formatter/actions/date-time/transformers/get-current-timestamp.js b/packages/backend/src/apps/formatter/actions/date-time/transformers/get-current-timestamp.js new file mode 100644 index 0000000..a0d7f0c --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/date-time/transformers/get-current-timestamp.js @@ -0,0 +1,5 @@ +const getCurrentTimestamp = () => { + return Date.now(); +}; + +export default getCurrentTimestamp; diff --git a/packages/backend/src/apps/formatter/actions/index.js b/packages/backend/src/apps/formatter/actions/index.js new file mode 100644 index 0000000..f7f07e5 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/index.js @@ -0,0 +1,5 @@ +import text from './text/index.js'; +import numbers from './numbers/index.js'; +import dateTime from './date-time/index.js'; + +export default [text, numbers, dateTime]; diff --git a/packages/backend/src/apps/formatter/actions/numbers/index.js b/packages/backend/src/apps/formatter/actions/numbers/index.js new file mode 100644 index 0000000..94aca22 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/numbers/index.js @@ -0,0 +1,60 @@ +import defineAction from '../../../../helpers/define-action.js'; + +import performMathOperation from './transformers/perform-math-operation.js'; +import randomNumber from './transformers/random-number.js'; +import formatNumber from './transformers/format-number.js'; +import formatPhoneNumber from './transformers/format-phone-number.js'; + +const transformers = { + performMathOperation, + randomNumber, + formatNumber, + formatPhoneNumber, +}; + +export default defineAction({ + name: 'Numbers', + key: 'numbers', + description: + 'Transform numbers to perform math operations, generate random numbers, format numbers, and much more.', + arguments: [ + { + label: 'Transform', + key: 'transform', + type: 'dropdown', + required: true, + variables: true, + options: [ + { label: 'Perform Math Operation', value: 'performMathOperation' }, + { label: 'Random Number', value: 'randomNumber' }, + { label: 'Format Number', value: 'formatNumber' }, + { label: 'Format Phone Number', value: 'formatPhoneNumber' }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listTransformOptions', + }, + { + name: 'parameters.transform', + value: '{parameters.transform}', + }, + ], + }, + }, + ], + + async run($) { + const transformerName = $.step.parameters.transform; + const output = transformers[transformerName]($); + + $.setActionItem({ + raw: { + output, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/formatter/actions/numbers/transformers/format-number.js b/packages/backend/src/apps/formatter/actions/numbers/transformers/format-number.js new file mode 100644 index 0000000..783ad01 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/numbers/transformers/format-number.js @@ -0,0 +1,27 @@ +import accounting from 'accounting'; + +const formatNumber = ($) => { + const input = $.step.parameters.input; + const inputDecimalMark = $.step.parameters.inputDecimalMark; + const toFormat = $.step.parameters.toFormat; + + const normalizedNumber = accounting.unformat(input, inputDecimalMark); + const decimalPart = normalizedNumber.toString().split('.')[1]; + const precision = decimalPart ? decimalPart.length : 0; + + if (toFormat === '0') { + // Comma for grouping & period for decimal + return accounting.formatNumber(normalizedNumber, precision, ',', '.'); + } else if (toFormat === '1') { + // Period for grouping & comma for decimal + return accounting.formatNumber(normalizedNumber, precision, '.', ','); + } else if (toFormat === '2') { + // Space for grouping & period for decimal + return accounting.formatNumber(normalizedNumber, precision, ' ', '.'); + } else if (toFormat === '3') { + // Space for grouping & comma for decimal + return accounting.formatNumber(normalizedNumber, precision, ' ', ','); + } +}; + +export default formatNumber; diff --git a/packages/backend/src/apps/formatter/actions/numbers/transformers/format-phone-number.js b/packages/backend/src/apps/formatter/actions/numbers/transformers/format-phone-number.js new file mode 100644 index 0000000..f9aca9a --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/numbers/transformers/format-phone-number.js @@ -0,0 +1,23 @@ +import parsePhoneNumber from 'libphonenumber-js'; + +const formatPhoneNumber = ($) => { + const phoneNumber = $.step.parameters.phoneNumber; + const toFormat = $.step.parameters.toFormat; + const phoneNumberCountryCode = + $.step.parameters.phoneNumberCountryCode || 'US'; + + const parsedPhoneNumber = parsePhoneNumber( + phoneNumber, + phoneNumberCountryCode + ); + + if (toFormat === 'e164') { + return parsedPhoneNumber.format('E.164'); + } else if (toFormat === 'international') { + return parsedPhoneNumber.formatInternational(); + } else if (toFormat === 'national') { + return parsedPhoneNumber.formatNational(); + } +}; + +export default formatPhoneNumber; diff --git a/packages/backend/src/apps/formatter/actions/numbers/transformers/perform-math-operation.js b/packages/backend/src/apps/formatter/actions/numbers/transformers/perform-math-operation.js new file mode 100644 index 0000000..e6127b6 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/numbers/transformers/perform-math-operation.js @@ -0,0 +1,23 @@ +import add from 'lodash/add.js'; +import divide from 'lodash/divide.js'; +import multiply from 'lodash/multiply.js'; +import subtract from 'lodash/subtract.js'; + +const mathOperation = ($) => { + const mathOperation = $.step.parameters.mathOperation; + const values = $.step.parameters.values.map((value) => Number(value.input)); + + if (mathOperation === 'add') { + return values.reduce((acc, curr) => add(acc, curr), 0); + } else if (mathOperation === 'divide') { + return values.reduce((acc, curr) => divide(acc, curr)); + } else if (mathOperation === 'makeNegative') { + return values.map((value) => -value); + } else if (mathOperation === 'multiply') { + return values.reduce((acc, curr) => multiply(acc, curr), 1); + } else if (mathOperation === 'subtract') { + return values.reduce((acc, curr) => subtract(acc, curr)); + } +}; + +export default mathOperation; diff --git a/packages/backend/src/apps/formatter/actions/numbers/transformers/random-number.js b/packages/backend/src/apps/formatter/actions/numbers/transformers/random-number.js new file mode 100644 index 0000000..3884824 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/numbers/transformers/random-number.js @@ -0,0 +1,13 @@ +const randomNumber = ($) => { + const lowerRange = Number($.step.parameters.lowerRange); + const upperRange = Number($.step.parameters.upperRange); + const decimalPoints = Number($.step.parameters.decimalPoints) || 0; + + return Number( + (Math.random() * (upperRange - lowerRange) + lowerRange).toFixed( + decimalPoints + ) + ); +}; + +export default randomNumber; diff --git a/packages/backend/src/apps/formatter/actions/text/index.js b/packages/backend/src/apps/formatter/actions/text/index.js new file mode 100644 index 0000000..e33160a --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/index.js @@ -0,0 +1,99 @@ +import defineAction from '../../../../helpers/define-action.js'; + +import base64ToString from './transformers/base64-to-string.js'; +import capitalize from './transformers/capitalize.js'; +import encodeUriComponent from './transformers/encode-uri-component.js'; +import extractEmailAddress from './transformers/extract-email-address.js'; +import extractNumber from './transformers/extract-number.js'; +import htmlToMarkdown from './transformers/html-to-markdown.js'; +import lowercase from './transformers/lowercase.js'; +import markdownToHtml from './transformers/markdown-to-html.js'; +import pluralize from './transformers/pluralize.js'; +import replace from './transformers/replace.js'; +import stringToBase64 from './transformers/string-to-base64.js'; +import encodeUri from './transformers/encode-uri.js'; +import trimWhitespace from './transformers/trim-whitespace.js'; +import useDefaultValue from './transformers/use-default-value.js'; +import parseStringifiedJson from './transformers/parse-stringified-json.js'; +import createUuid from './transformers/create-uuid.js'; + +const transformers = { + base64ToString, + capitalize, + encodeUriComponent, + extractEmailAddress, + extractNumber, + htmlToMarkdown, + lowercase, + markdownToHtml, + pluralize, + replace, + stringToBase64, + encodeUri, + trimWhitespace, + useDefaultValue, + parseStringifiedJson, + createUuid, +}; + +export default defineAction({ + name: 'Text', + key: 'text', + description: + 'Transform text data to capitalize, extract emails, apply default value, and much more.', + arguments: [ + { + label: 'Transform', + key: 'transform', + type: 'dropdown', + required: true, + variables: true, + options: [ + { label: 'Base64 to String', value: 'base64ToString' }, + { label: 'Capitalize', value: 'capitalize' }, + { label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' }, + { label: 'Convert Markdown to HTML', value: 'markdownToHtml' }, + { label: 'Create UUID', value: 'createUuid' }, + { label: 'Encode URI', value: 'encodeUri' }, + { + label: 'Encode URI Component', + value: 'encodeUriComponent', + }, + { label: 'Extract Email Address', value: 'extractEmailAddress' }, + { label: 'Extract Number', value: 'extractNumber' }, + { label: 'Lowercase', value: 'lowercase' }, + { label: 'Parse stringified JSON', value: 'parseStringifiedJson' }, + { label: 'Pluralize', value: 'pluralize' }, + { label: 'Replace', value: 'replace' }, + { label: 'String to Base64', value: 'stringToBase64' }, + { label: 'Trim Whitespace', value: 'trimWhitespace' }, + { label: 'Use Default Value', value: 'useDefaultValue' }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listTransformOptions', + }, + { + name: 'parameters.transform', + value: '{parameters.transform}', + }, + ], + }, + }, + ], + + async run($) { + const transformerName = $.step.parameters.transform; + const output = transformers[transformerName]($); + + $.setActionItem({ + raw: { + output, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/base64-to-string.js b/packages/backend/src/apps/formatter/actions/text/transformers/base64-to-string.js new file mode 100644 index 0000000..7e4e397 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/base64-to-string.js @@ -0,0 +1,8 @@ +const base64ToString = ($) => { + const input = $.step.parameters.input; + const decodedString = Buffer.from(input, 'base64').toString('utf8'); + + return decodedString; +}; + +export default base64ToString; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/capitalize.js b/packages/backend/src/apps/formatter/actions/text/transformers/capitalize.js new file mode 100644 index 0000000..b886806 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/capitalize.js @@ -0,0 +1,10 @@ +import lodashCapitalize from 'lodash/capitalize.js'; + +const capitalize = ($) => { + const input = $.step.parameters.input; + const capitalizedInput = input.replace(/\w+/g, lodashCapitalize); + + return capitalizedInput; +}; + +export default capitalize; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/create-uuid.js b/packages/backend/src/apps/formatter/actions/text/transformers/create-uuid.js new file mode 100644 index 0000000..20d1ecc --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/create-uuid.js @@ -0,0 +1,7 @@ +import { v4 as uuidv4 } from 'uuid'; + +const createUuidV4 = () => { + return uuidv4(); +}; + +export default createUuidV4; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/encode-uri-component.js b/packages/backend/src/apps/formatter/actions/text/transformers/encode-uri-component.js new file mode 100644 index 0000000..8d211fc --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/encode-uri-component.js @@ -0,0 +1,8 @@ +const encodeUriComponent = ($) => { + const input = $.step.parameters.input; + const encodedString = encodeURIComponent(input); + + return encodedString; +}; + +export default encodeUriComponent; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/encode-uri.js b/packages/backend/src/apps/formatter/actions/text/transformers/encode-uri.js new file mode 100644 index 0000000..0633300 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/encode-uri.js @@ -0,0 +1,8 @@ +const encodeUri = ($) => { + const input = $.step.parameters.input; + const encodedString = encodeURI(input); + + return encodedString; +}; + +export default encodeUri; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/extract-email-address.js b/packages/backend/src/apps/formatter/actions/text/transformers/extract-email-address.js new file mode 100644 index 0000000..127c554 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/extract-email-address.js @@ -0,0 +1,10 @@ +const extractEmailAddress = ($) => { + const input = $.step.parameters.input; + const emailRegexp = + /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; + + const email = input.match(emailRegexp); + return email ? email[0] : ''; +}; + +export default extractEmailAddress; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/extract-number.js b/packages/backend/src/apps/formatter/actions/text/transformers/extract-number.js new file mode 100644 index 0000000..70abd58 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/extract-number.js @@ -0,0 +1,24 @@ +const extractNumber = ($) => { + const input = $.step.parameters.input; + + // Example numbers that's supported: + // 123 + // -123 + // 123456 + // -123456 + // 121,234 + // -121,234 + // 121.234 + // -121.234 + // 1,234,567.89 + // -1,234,567.89 + // 1.234.567,89 + // -1.234.567,89 + + const numberRegexp = /-?((\d{1,3})+\.?,?)+/g; + + const numbers = input.match(numberRegexp); + return numbers ? numbers[0] : ''; +}; + +export default extractNumber; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/html-to-markdown.js b/packages/backend/src/apps/formatter/actions/text/transformers/html-to-markdown.js new file mode 100644 index 0000000..adda99f --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/html-to-markdown.js @@ -0,0 +1,10 @@ +import { NodeHtmlMarkdown } from 'node-html-markdown'; + +const htmlToMarkdown = ($) => { + const input = $.step.parameters.input; + + const markdown = NodeHtmlMarkdown.translate(input); + return markdown; +}; + +export default htmlToMarkdown; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/lowercase.js b/packages/backend/src/apps/formatter/actions/text/transformers/lowercase.js new file mode 100644 index 0000000..7d712a5 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/lowercase.js @@ -0,0 +1,6 @@ +const lowercase = ($) => { + const input = $.step.parameters.input; + return input.toLowerCase(); +}; + +export default lowercase; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/markdown-to-html.js b/packages/backend/src/apps/formatter/actions/text/transformers/markdown-to-html.js new file mode 100644 index 0000000..47bdaf3 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/markdown-to-html.js @@ -0,0 +1,12 @@ +import showdown from 'showdown'; + +const converter = new showdown.Converter(); + +const markdownToHtml = ($) => { + const input = $.step.parameters.input; + + const html = converter.makeHtml(input); + return html; +}; + +export default markdownToHtml; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/parse-stringified-json.js b/packages/backend/src/apps/formatter/actions/text/transformers/parse-stringified-json.js new file mode 100644 index 0000000..26cedb3 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/parse-stringified-json.js @@ -0,0 +1,7 @@ +const parseStringifiedJson = ($) => { + const input = $.step.parameters.input; + + return JSON.parse(input); +}; + +export default parseStringifiedJson; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/pluralize.js b/packages/backend/src/apps/formatter/actions/text/transformers/pluralize.js new file mode 100644 index 0000000..8ba219e --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/pluralize.js @@ -0,0 +1,8 @@ +import pluralizeLibrary from 'pluralize'; + +const pluralize = ($) => { + const input = $.step.parameters.input; + return pluralizeLibrary(input); +}; + +export default pluralize; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/replace.js b/packages/backend/src/apps/formatter/actions/text/transformers/replace.js new file mode 100644 index 0000000..401ae46 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/replace.js @@ -0,0 +1,28 @@ +const replace = ($) => { + const input = $.step.parameters.input; + const find = $.step.parameters.find; + const replace = $.step.parameters.replace; + const useRegex = $.step.parameters.useRegex; + + if (useRegex) { + const ignoreCase = $.step.parameters.ignoreCase; + + const flags = [ignoreCase && 'i', 'g'].filter(Boolean).join(''); + + const timeoutId = setTimeout(() => { + $.execution.exit(); + }, 100); + + const regex = new RegExp(find, flags); + + const replacedValue = input.replaceAll(regex, replace); + + clearTimeout(timeoutId); + + return replacedValue; + } + + return input.replaceAll(find, replace); +}; + +export default replace; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/string-to-base64.js b/packages/backend/src/apps/formatter/actions/text/transformers/string-to-base64.js new file mode 100644 index 0000000..0b7052f --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/string-to-base64.js @@ -0,0 +1,8 @@ +const stringtoBase64 = ($) => { + const input = $.step.parameters.input; + const base64String = Buffer.from(input).toString('base64'); + + return base64String; +}; + +export default stringtoBase64; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/trim-whitespace.js b/packages/backend/src/apps/formatter/actions/text/transformers/trim-whitespace.js new file mode 100644 index 0000000..1ee8102 --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/trim-whitespace.js @@ -0,0 +1,6 @@ +const trimWhitespace = ($) => { + const input = $.step.parameters.input; + return input.trim(); +}; + +export default trimWhitespace; diff --git a/packages/backend/src/apps/formatter/actions/text/transformers/use-default-value.js b/packages/backend/src/apps/formatter/actions/text/transformers/use-default-value.js new file mode 100644 index 0000000..54bc61a --- /dev/null +++ b/packages/backend/src/apps/formatter/actions/text/transformers/use-default-value.js @@ -0,0 +1,11 @@ +const useDefaultValue = ($) => { + const input = $.step.parameters.input; + + if (input && input.trim().length > 0) { + return input; + } + + return $.step.parameters.defaultValue; +}; + +export default useDefaultValue; diff --git a/packages/backend/src/apps/formatter/assets/favicon.svg b/packages/backend/src/apps/formatter/assets/favicon.svg new file mode 100644 index 0000000..858aed3 --- /dev/null +++ b/packages/backend/src/apps/formatter/assets/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/backend/src/apps/formatter/common/phone-number-country-codes.js b/packages/backend/src/apps/formatter/common/phone-number-country-codes.js new file mode 100644 index 0000000..f4512f8 --- /dev/null +++ b/packages/backend/src/apps/formatter/common/phone-number-country-codes.js @@ -0,0 +1,249 @@ +const phoneNumberCountryCodes = [ + { label: 'Ascension Island', value: 'AC' }, + { label: 'Andorra', value: 'AD' }, + { label: 'United Arab Emirates', value: 'AE' }, + { label: 'Afghanistan', value: 'AF' }, + { label: 'Antigua & Barbuda', value: 'AG' }, + { label: 'Anguilla', value: 'AI' }, + { label: 'Albania', value: 'AL' }, + { label: 'Armenia', value: 'AM' }, + { label: 'Angola', value: 'AO' }, + { label: 'Argentina', value: 'AR' }, + { label: 'American Samoa', value: 'AS' }, + { label: 'Austria', value: 'AT' }, + { label: 'Australia', value: 'AU' }, + { label: 'Aruba', value: 'AW' }, + { label: 'Åland Islands', value: 'AX' }, + { label: 'Azerbaijan', value: 'AZ' }, + { label: 'Bosnia & Herzegovina', value: 'BA' }, + { label: 'Barbados', value: 'BB' }, + { label: 'Bangladesh', value: 'BD' }, + { label: 'Belgium', value: 'BE' }, + { label: 'Burkina Faso', value: 'BF' }, + { label: 'Bulgaria', value: 'BG' }, + { label: 'Bahrain', value: 'BH' }, + { label: 'Burundi', value: 'BI' }, + { label: 'Benin', value: 'BJ' }, + { label: 'St. Barthélemy', value: 'BL' }, + { label: 'Bermuda', value: 'BM' }, + { label: 'Brunei', value: 'BN' }, + { label: 'Bolivia', value: 'BO' }, + { label: 'Caribbean Netherlands', value: 'BQ' }, + { label: 'Brazil', value: 'BR' }, + { label: 'Bahamas', value: 'BS' }, + { label: 'Bhutan', value: 'BT' }, + { label: 'Botswana', value: 'BW' }, + { label: 'Belarus', value: 'BY' }, + { label: 'Belize', value: 'BZ' }, + { label: 'Canada', value: 'CA' }, + { label: 'Cocos (Keeling) Islands', value: 'CC' }, + { label: 'Congo - Kinshasa', value: 'CD' }, + { label: 'Central African Republic', value: 'CF' }, + { label: 'Congo - Brazzaville', value: 'CG' }, + { label: 'Switzerland', value: 'CH' }, + { label: 'Côte d’Ivoire', value: 'CI' }, + { label: 'Cook Islands', value: 'CK' }, + { label: 'Chile', value: 'CL' }, + { label: 'Cameroon', value: 'CM' }, + { label: 'China', value: 'CN' }, + { label: 'Colombia', value: 'CO' }, + { label: 'Costa Rica', value: 'CR' }, + { label: 'Cuba', value: 'CU' }, + { label: 'Cape Verde', value: 'CV' }, + { label: 'Curaçao', value: 'CW' }, + { label: 'Christmas Island', value: 'CX' }, + { label: 'Cyprus', value: 'CY' }, + { label: 'Czechia', value: 'CZ' }, + { label: 'Germany', value: 'DE' }, + { label: 'Djibouti', value: 'DJ' }, + { label: 'Denmark', value: 'DK' }, + { label: 'Dominica', value: 'DM' }, + { label: 'Dominican Republic', value: 'DO' }, + { label: 'Algeria', value: 'DZ' }, + { label: 'Ecuador', value: 'EC' }, + { label: 'Estonia', value: 'EE' }, + { label: 'Egypt', value: 'EG' }, + { label: 'Western Sahara', value: 'EH' }, + { label: 'Eritrea', value: 'ER' }, + { label: 'Spain', value: 'ES' }, + { label: 'Ethiopia', value: 'ET' }, + { label: 'Finland', value: 'FI' }, + { label: 'Fiji', value: 'FJ' }, + { label: 'Falkland Islands (Islas Malvinas)', value: 'FK' }, + { label: 'Micronesia', value: 'FM' }, + { label: 'Faroe Islands', value: 'FO' }, + { label: 'France', value: 'FR' }, + { label: 'Gabon', value: 'GA' }, + { label: 'United Kingdom', value: 'GB' }, + { label: 'Grenada', value: 'GD' }, + { label: 'Georgia', value: 'GE' }, + { label: 'French Guiana', value: 'GF' }, + { label: 'Guernsey', value: 'GG' }, + { label: 'Ghana', value: 'GH' }, + { label: 'Gibraltar', value: 'GI' }, + { label: 'Greenland', value: 'GL' }, + { label: 'Gambia', value: 'GM' }, + { label: 'Guinea', value: 'GN' }, + { label: 'Guadeloupe', value: 'GP' }, + { label: 'Equatorial Guinea', value: 'GQ' }, + { label: 'Greece', value: 'GR' }, + { label: 'Guatemala', value: 'GT' }, + { label: 'Guam', value: 'GU' }, + { label: 'Guinea-Bissau', value: 'GW' }, + { label: 'Guyana', value: 'GY' }, + { label: 'Hong Kong', value: 'HK' }, + { label: 'Honduras', value: 'HN' }, + { label: 'Croatia', value: 'HR' }, + { label: 'Haiti', value: 'HT' }, + { label: 'Hungary', value: 'HU' }, + { label: 'Indonesia', value: 'ID' }, + { label: 'Ireland', value: 'IE' }, + { label: 'Israel', value: 'IL' }, + { label: 'Isle of Man', value: 'IM' }, + { label: 'India', value: 'IN' }, + { label: 'British Indian Ocean Territory', value: 'IO' }, + { label: 'Iraq', value: 'IQ' }, + { label: 'Iran', value: 'IR' }, + { label: 'Iceland', value: 'IS' }, + { label: 'Italy', value: 'IT' }, + { label: 'Jersey', value: 'JE' }, + { label: 'Jamaica', value: 'JM' }, + { label: 'Jordan', value: 'JO' }, + { label: 'Japan', value: 'JP' }, + { label: 'Kenya', value: 'KE' }, + { label: 'Kyrgyzstan', value: 'KG' }, + { label: 'Cambodia', value: 'KH' }, + { label: 'Kiribati', value: 'KI' }, + { label: 'Comoros', value: 'KM' }, + { label: 'St. Kitts & Nevis', value: 'KN' }, + { label: 'North Korea', value: 'KP' }, + { label: 'South Korea', value: 'KR' }, + { label: 'Kuwait', value: 'KW' }, + { label: 'Cayman Islands', value: 'KY' }, + { label: 'Kazakhstan', value: 'KZ' }, + { label: 'Laos', value: 'LA' }, + { label: 'Lebanon', value: 'LB' }, + { label: 'St. Lucia', value: 'LC' }, + { label: 'Liechtenstein', value: 'LI' }, + { label: 'Sri Lanka', value: 'LK' }, + { label: 'Liberia', value: 'LR' }, + { label: 'Lesotho', value: 'LS' }, + { label: 'Lithuania', value: 'LT' }, + { label: 'Luxembourg', value: 'LU' }, + { label: 'Latvia', value: 'LV' }, + { label: 'Libya', value: 'LY' }, + { label: 'Morocco', value: 'MA' }, + { label: 'Monaco', value: 'MC' }, + { label: 'Moldova', value: 'MD' }, + { label: 'Montenegro', value: 'ME' }, + { label: 'St. Martin', value: 'MF' }, + { label: 'Madagascar', value: 'MG' }, + { label: 'Marshall Islands', value: 'MH' }, + { label: 'North Macedonia', value: 'MK' }, + { label: 'Mali', value: 'ML' }, + { label: 'Myanmar (Burma)', value: 'MM' }, + { label: 'Mongolia', value: 'MN' }, + { label: 'Macao', value: 'MO' }, + { label: 'Northern Mariana Islands', value: 'MP' }, + { label: 'Martinique', value: 'MQ' }, + { label: 'Mauritania', value: 'MR' }, + { label: 'Montserrat', value: 'MS' }, + { label: 'Malta', value: 'MT' }, + { label: 'Mauritius', value: 'MU' }, + { label: 'Maldives', value: 'MV' }, + { label: 'Malawi', value: 'MW' }, + { label: 'Mexico', value: 'MX' }, + { label: 'Malaysia', value: 'MY' }, + { label: 'Mozambique', value: 'MZ' }, + { label: 'Namibia', value: 'NA' }, + { label: 'New Caledonia', value: 'NC' }, + { label: 'Niger', value: 'NE' }, + { label: 'Norfolk Island', value: 'NF' }, + { label: 'Nigeria', value: 'NG' }, + { label: 'Nicaragua', value: 'NI' }, + { label: 'Netherlands', value: 'NL' }, + { label: 'Norway', value: 'NO' }, + { label: 'Nepal', value: 'NP' }, + { label: 'Nauru', value: 'NR' }, + { label: 'Niue', value: 'NU' }, + { label: 'New Zealand', value: 'NZ' }, + { label: 'Oman', value: 'OM' }, + { label: 'Panama', value: 'PA' }, + { label: 'Peru', value: 'PE' }, + { label: 'French Polynesia', value: 'PF' }, + { label: 'Papua New Guinea', value: 'PG' }, + { label: 'Philippines', value: 'PH' }, + { label: 'Pakistan', value: 'PK' }, + { label: 'Poland', value: 'PL' }, + { label: 'St. Pierre & Miquelon', value: 'PM' }, + { label: 'Puerto Rico', value: 'PR' }, + { label: 'Palestine', value: 'PS' }, + { label: 'Portugal', value: 'PT' }, + { label: 'Palau', value: 'PW' }, + { label: 'Paraguay', value: 'PY' }, + { label: 'Qatar', value: 'QA' }, + { label: 'Réunion', value: 'RE' }, + { label: 'Romania', value: 'RO' }, + { label: 'Serbia', value: 'RS' }, + { label: 'Russia', value: 'RU' }, + { label: 'Rwanda', value: 'RW' }, + { label: 'Saudi Arabia', value: 'SA' }, + { label: 'Solomon Islands', value: 'SB' }, + { label: 'Seychelles', value: 'SC' }, + { label: 'Sudan', value: 'SD' }, + { label: 'Sweden', value: 'SE' }, + { label: 'Singapore', value: 'SG' }, + { label: 'St. Helena', value: 'SH' }, + { label: 'Slovenia', value: 'SI' }, + { label: 'Svalbard & Jan Mayen', value: 'SJ' }, + { label: 'Slovakia', value: 'SK' }, + { label: 'Sierra Leone', value: 'SL' }, + { label: 'San Marino', value: 'SM' }, + { label: 'Senegal', value: 'SN' }, + { label: 'Somalia', value: 'SO' }, + { label: 'Suriname', value: 'SR' }, + { label: 'South Sudan', value: 'SS' }, + { label: 'São Tomé & Príncipe', value: 'ST' }, + { label: 'El Salvador', value: 'SV' }, + { label: 'Sint Maarten', value: 'SX' }, + { label: 'Syria', value: 'SY' }, + { label: 'Eswatini', value: 'SZ' }, + { label: 'Tristan da Cunha', value: 'TA' }, + { label: 'Turks & Caicos Islands', value: 'TC' }, + { label: 'Chad', value: 'TD' }, + { label: 'Togo', value: 'TG' }, + { label: 'Thailand', value: 'TH' }, + { label: 'Tajikistan', value: 'TJ' }, + { label: 'Tokelau', value: 'TK' }, + { label: 'Timor-Leste', value: 'TL' }, + { label: 'Turkmenistan', value: 'TM' }, + { label: 'Tunisia', value: 'TN' }, + { label: 'Tonga', value: 'TO' }, + { label: 'Türkiye', value: 'TR' }, + { label: 'Trinidad & Tobago', value: 'TT' }, + { label: 'Tuvalu', value: 'TV' }, + { label: 'Taiwan', value: 'TW' }, + { label: 'Tanzania', value: 'TZ' }, + { label: 'Ukraine', value: 'UA' }, + { label: 'Uganda', value: 'UG' }, + { label: 'United States', value: 'US' }, + { label: 'Uruguay', value: 'UY' }, + { label: 'Uzbekistan', value: 'UZ' }, + { label: 'Vatican City', value: 'VA' }, + { label: 'St. Vincent & Grenadines', value: 'VC' }, + { label: 'Venezuela', value: 'VE' }, + { label: 'British Virgin Islands', value: 'VG' }, + { label: 'U.S. Virgin Islands', value: 'VI' }, + { label: 'Vietnam', value: 'VN' }, + { label: 'Vanuatu', value: 'VU' }, + { label: 'Wallis & Futuna', value: 'WF' }, + { label: 'Samoa', value: 'WS' }, + { label: 'Kosovo', value: 'XK' }, + { label: 'Yemen', value: 'YE' }, + { label: 'Mayotte', value: 'YT' }, + { label: 'South Africa', value: 'ZA' }, + { label: 'Zambia', value: 'ZM' }, + { label: 'Zimbabwe', value: 'ZW' }, +]; + +export default phoneNumberCountryCodes; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/index.js b/packages/backend/src/apps/formatter/dynamic-fields/index.js new file mode 100644 index 0000000..7ab9d1e --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/index.js @@ -0,0 +1,4 @@ +import listTransformOptions from './list-transform-options/index.js'; +import listReplaceRegexOptions from './list-replace-regex-options/index.js'; + +export default [listTransformOptions, listReplaceRegexOptions]; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-replace-regex-options/index.js b/packages/backend/src/apps/formatter/dynamic-fields/list-replace-regex-options/index.js new file mode 100644 index 0000000..6c62a28 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-replace-regex-options/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List replace regex options', + key: 'listReplaceRegexOptions', + + async run($) { + if (!$.step.parameters.useRegex) return []; + + return [ + { + label: 'Ignore case', + key: 'ignoreCase', + type: 'dropdown', + required: true, + description: 'Ignore case sensitivity.', + variables: true, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + ]; + }, +}; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/format-date-time.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/format-date-time.js new file mode 100644 index 0000000..7dc47c0 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/format-date-time.js @@ -0,0 +1,51 @@ +import formatOptions from './options/format.js'; +import timezoneOptions from './options/timezone.js'; + +const formatDateTime = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'The datetime you want to format.', + variables: true, + }, + { + label: 'From Format', + key: 'fromFormat', + type: 'dropdown', + required: true, + description: 'The format of the input.', + variables: true, + options: formatOptions, + }, + { + label: 'From Timezone', + key: 'fromTimezone', + type: 'dropdown', + required: true, + description: 'The timezone of the input.', + variables: true, + options: timezoneOptions, + }, + { + label: 'To Format', + key: 'toFormat', + type: 'dropdown', + required: true, + description: 'The format of the output.', + variables: true, + options: formatOptions, + }, + { + label: 'To Timezone', + key: 'toTimezone', + type: 'dropdown', + required: true, + description: 'The timezone of the output.', + variables: true, + options: timezoneOptions, + }, +]; + +export default formatDateTime; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/format.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/format.js new file mode 100644 index 0000000..24bf8da --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/format.js @@ -0,0 +1,64 @@ +const formatOptions = [ + { + label: 'ccc MMM dd HH:mm:ssZZZ yyyy (Wed Aug 23 12:25:36-0000 2023)', + value: 'ccc MMM dd HH:mm:ssZZZ yyyy', + }, + { + label: 'MMMM dd yyyy HH:mm:ss (August 23 2023 12:25:36)', + value: 'MMMM dd yyyy HH:mm:ss', + }, + { + label: 'MMMM dd yyyy (August 23 2023)', + value: 'MMMM dd yyyy', + }, + { + label: 'MMM dd yyyy (Aug 23 2023)', + value: 'MMM dd yyyy', + }, + { + label: 'yyyy-MM-dd HH:mm:ss ZZZ (2023-08-23 12:25:36 -0000)', + value: 'yyyy-MM-dd HH:mm:ss ZZZ', + }, + { + label: 'yyyy-MM-dd (2023-08-23)', + value: 'yyyy-MM-dd', + }, + { + label: 'MM-dd-yyyy (08-23-2023)', + value: 'MM-dd-yyyy', + }, + { + label: 'MM/dd/yyyy (08/23/2023)', + value: 'MM/dd/yyyy', + }, + { + label: 'MM/dd/yy (08/23/23)', + value: 'MM/dd/yy', + }, + { + label: 'dd-MM-yyyy (23-08-2023)', + value: 'dd-MM-yyyy', + }, + { + label: 'dd/MM/yyyy (23/08/2023)', + value: 'dd/MM/yyyy', + }, + { + label: 'dd/MM/yy (23/08/23)', + value: 'dd/MM/yy', + }, + { + label: 'MM-yyyy (08-2023)', + value: 'MM-yyyy', + }, + { + label: 'Unix timestamp in seconds (1694008283)', + value: 'X', + }, + { + label: 'Unix timestamp in milliseconds (1694008306315)', + value: 'x', + }, +]; + +export default formatOptions; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/timezone.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/timezone.js new file mode 100644 index 0000000..f48ec66 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/date-time/options/timezone.js @@ -0,0 +1,449 @@ +// The list from Intl.supportedValuesOf('timeZone') which is used by Luxon. + +const timezoneOptions = [ + { label: 'Africa/Abidjan', value: 'Africa/Abidjan' }, + { label: 'Africa/Accra', value: 'Africa/Accra' }, + { label: 'Africa/Addis_Ababa', value: 'Africa/Addis_Ababa' }, + { label: 'Africa/Algiers', value: 'Africa/Algiers' }, + { label: 'Africa/Asmera', value: 'Africa/Asmera' }, + { label: 'Africa/Bamako', value: 'Africa/Bamako' }, + { label: 'Africa/Bangui', value: 'Africa/Bangui' }, + { label: 'Africa/Banjul', value: 'Africa/Banjul' }, + { label: 'Africa/Bissau', value: 'Africa/Bissau' }, + { label: 'Africa/Blantyre', value: 'Africa/Blantyre' }, + { label: 'Africa/Brazzaville', value: 'Africa/Brazzaville' }, + { label: 'Africa/Bujumbura', value: 'Africa/Bujumbura' }, + { label: 'Africa/Cairo', value: 'Africa/Cairo' }, + { label: 'Africa/Casablanca', value: 'Africa/Casablanca' }, + { label: 'Africa/Ceuta', value: 'Africa/Ceuta' }, + { label: 'Africa/Conakry', value: 'Africa/Conakry' }, + { label: 'Africa/Dakar', value: 'Africa/Dakar' }, + { label: 'Africa/Dar_es_Salaam', value: 'Africa/Dar_es_Salaam' }, + { label: 'Africa/Djibouti', value: 'Africa/Djibouti' }, + { label: 'Africa/Douala', value: 'Africa/Douala' }, + { label: 'Africa/El_Aaiun', value: 'Africa/El_Aaiun' }, + { label: 'Africa/Freetown', value: 'Africa/Freetown' }, + { label: 'Africa/Gaborone', value: 'Africa/Gaborone' }, + { label: 'Africa/Harare', value: 'Africa/Harare' }, + { label: 'Africa/Johannesburg', value: 'Africa/Johannesburg' }, + { label: 'Africa/Juba', value: 'Africa/Juba' }, + { label: 'Africa/Kampala', value: 'Africa/Kampala' }, + { label: 'Africa/Khartoum', value: 'Africa/Khartoum' }, + { label: 'Africa/Kigali', value: 'Africa/Kigali' }, + { label: 'Africa/Kinshasa', value: 'Africa/Kinshasa' }, + { label: 'Africa/Lagos', value: 'Africa/Lagos' }, + { label: 'Africa/Libreville', value: 'Africa/Libreville' }, + { label: 'Africa/Lome', value: 'Africa/Lome' }, + { label: 'Africa/Luanda', value: 'Africa/Luanda' }, + { label: 'Africa/Lubumbashi', value: 'Africa/Lubumbashi' }, + { label: 'Africa/Lusaka', value: 'Africa/Lusaka' }, + { label: 'Africa/Malabo', value: 'Africa/Malabo' }, + { label: 'Africa/Maputo', value: 'Africa/Maputo' }, + { label: 'Africa/Maseru', value: 'Africa/Maseru' }, + { label: 'Africa/Mbabane', value: 'Africa/Mbabane' }, + { label: 'Africa/Mogadishu', value: 'Africa/Mogadishu' }, + { label: 'Africa/Monrovia', value: 'Africa/Monrovia' }, + { label: 'Africa/Nairobi', value: 'Africa/Nairobi' }, + { label: 'Africa/Ndjamena', value: 'Africa/Ndjamena' }, + { label: 'Africa/Niamey', value: 'Africa/Niamey' }, + { label: 'Africa/Nouakchott', value: 'Africa/Nouakchott' }, + { label: 'Africa/Ouagadougou', value: 'Africa/Ouagadougou' }, + { label: 'Africa/Porto-Novo', value: 'Africa/Porto-Novo' }, + { label: 'Africa/Sao_Tome', value: 'Africa/Sao_Tome' }, + { label: 'Africa/Tripoli', value: 'Africa/Tripoli' }, + { label: 'Africa/Tunis', value: 'Africa/Tunis' }, + { label: 'Africa/Windhoek', value: 'Africa/Windhoek' }, + { label: 'America/Adak', value: 'America/Adak' }, + { label: 'America/Anchorage', value: 'America/Anchorage' }, + { label: 'America/Anguilla', value: 'America/Anguilla' }, + { label: 'America/Antigua', value: 'America/Antigua' }, + { label: 'America/Araguaina', value: 'America/Araguaina' }, + { label: 'America/Argentina/La_Rioja', value: 'America/Argentina/La_Rioja' }, + { + label: 'America/Argentina/Rio_Gallegos', + value: 'America/Argentina/Rio_Gallegos', + }, + { label: 'America/Argentina/Salta', value: 'America/Argentina/Salta' }, + { label: 'America/Argentina/San_Juan', value: 'America/Argentina/San_Juan' }, + { label: 'America/Argentina/San_Luis', value: 'America/Argentina/San_Luis' }, + { label: 'America/Argentina/Tucuman', value: 'America/Argentina/Tucuman' }, + { label: 'America/Argentina/Ushuaia', value: 'America/Argentina/Ushuaia' }, + { label: 'America/Aruba', value: 'America/Aruba' }, + { label: 'America/Asuncion', value: 'America/Asuncion' }, + { label: 'America/Bahia', value: 'America/Bahia' }, + { label: 'America/Bahia_Banderas', value: 'America/Bahia_Banderas' }, + { label: 'America/Barbados', value: 'America/Barbados' }, + { label: 'America/Belem', value: 'America/Belem' }, + { label: 'America/Belize', value: 'America/Belize' }, + { label: 'America/Blanc-Sablon', value: 'America/Blanc-Sablon' }, + { label: 'America/Boa_Vista', value: 'America/Boa_Vista' }, + { label: 'America/Bogota', value: 'America/Bogota' }, + { label: 'America/Boise', value: 'America/Boise' }, + { label: 'America/Buenos_Aires', value: 'America/Buenos_Aires' }, + { label: 'America/Cambridge_Bay', value: 'America/Cambridge_Bay' }, + { label: 'America/Campo_Grande', value: 'America/Campo_Grande' }, + { label: 'America/Cancun', value: 'America/Cancun' }, + { label: 'America/Caracas', value: 'America/Caracas' }, + { label: 'America/Catamarca', value: 'America/Catamarca' }, + { label: 'America/Cayenne', value: 'America/Cayenne' }, + { label: 'America/Cayman', value: 'America/Cayman' }, + { label: 'America/Chicago', value: 'America/Chicago' }, + { label: 'America/Chihuahua', value: 'America/Chihuahua' }, + { label: 'America/Ciudad_Juarez', value: 'America/Ciudad_Juarez' }, + { label: 'America/Coral_Harbour', value: 'America/Coral_Harbour' }, + { label: 'America/Cordoba', value: 'America/Cordoba' }, + { label: 'America/Costa_Rica', value: 'America/Costa_Rica' }, + { label: 'America/Creston', value: 'America/Creston' }, + { label: 'America/Cuiaba', value: 'America/Cuiaba' }, + { label: 'America/Curacao', value: 'America/Curacao' }, + { label: 'America/Danmarkshavn', value: 'America/Danmarkshavn' }, + { label: 'America/Dawson', value: 'America/Dawson' }, + { label: 'America/Dawson_Creek', value: 'America/Dawson_Creek' }, + { label: 'America/Denver', value: 'America/Denver' }, + { label: 'America/Detroit', value: 'America/Detroit' }, + { label: 'America/Dominica', value: 'America/Dominica' }, + { label: 'America/Edmonton', value: 'America/Edmonton' }, + { label: 'America/Eirunepe', value: 'America/Eirunepe' }, + { label: 'America/El_Salvador', value: 'America/El_Salvador' }, + { label: 'America/Fort_Nelson', value: 'America/Fort_Nelson' }, + { label: 'America/Fortaleza', value: 'America/Fortaleza' }, + { label: 'America/Glace_Bay', value: 'America/Glace_Bay' }, + { label: 'America/Godthab', value: 'America/Godthab' }, + { label: 'America/Goose_Bay', value: 'America/Goose_Bay' }, + { label: 'America/Grand_Turk', value: 'America/Grand_Turk' }, + { label: 'America/Grenada', value: 'America/Grenada' }, + { label: 'America/Guadeloupe', value: 'America/Guadeloupe' }, + { label: 'America/Guatemala', value: 'America/Guatemala' }, + { label: 'America/Guayaquil', value: 'America/Guayaquil' }, + { label: 'America/Guyana', value: 'America/Guyana' }, + { label: 'America/Halifax', value: 'America/Halifax' }, + { label: 'America/Havana', value: 'America/Havana' }, + { label: 'America/Hermosillo', value: 'America/Hermosillo' }, + { label: 'America/Indiana/Knox', value: 'America/Indiana/Knox' }, + { label: 'America/Indiana/Marengo', value: 'America/Indiana/Marengo' }, + { label: 'America/Indiana/Petersburg', value: 'America/Indiana/Petersburg' }, + { label: 'America/Indiana/Tell_City', value: 'America/Indiana/Tell_City' }, + { label: 'America/Indiana/Vevay', value: 'America/Indiana/Vevay' }, + { label: 'America/Indiana/Vincennes', value: 'America/Indiana/Vincennes' }, + { label: 'America/Indiana/Winamac', value: 'America/Indiana/Winamac' }, + { label: 'America/Indianapolis', value: 'America/Indianapolis' }, + { label: 'America/Inuvik', value: 'America/Inuvik' }, + { label: 'America/Iqaluit', value: 'America/Iqaluit' }, + { label: 'America/Jamaica', value: 'America/Jamaica' }, + { label: 'America/Jujuy', value: 'America/Jujuy' }, + { label: 'America/Juneau', value: 'America/Juneau' }, + { + label: 'America/Kentucky/Monticello', + value: 'America/Kentucky/Monticello', + }, + { label: 'America/Kralendijk', value: 'America/Kralendijk' }, + { label: 'America/La_Paz', value: 'America/La_Paz' }, + { label: 'America/Lima', value: 'America/Lima' }, + { label: 'America/Los_Angeles', value: 'America/Los_Angeles' }, + { label: 'America/Louisville', value: 'America/Louisville' }, + { label: 'America/Lower_Princes', value: 'America/Lower_Princes' }, + { label: 'America/Maceio', value: 'America/Maceio' }, + { label: 'America/Managua', value: 'America/Managua' }, + { label: 'America/Manaus', value: 'America/Manaus' }, + { label: 'America/Marigot', value: 'America/Marigot' }, + { label: 'America/Martinique', value: 'America/Martinique' }, + { label: 'America/Matamoros', value: 'America/Matamoros' }, + { label: 'America/Mazatlan', value: 'America/Mazatlan' }, + { label: 'America/Mendoza', value: 'America/Mendoza' }, + { label: 'America/Menominee', value: 'America/Menominee' }, + { label: 'America/Merida', value: 'America/Merida' }, + { label: 'America/Metlakatla', value: 'America/Metlakatla' }, + { label: 'America/Mexico_City', value: 'America/Mexico_City' }, + { label: 'America/Miquelon', value: 'America/Miquelon' }, + { label: 'America/Moncton', value: 'America/Moncton' }, + { label: 'America/Monterrey', value: 'America/Monterrey' }, + { label: 'America/Montevideo', value: 'America/Montevideo' }, + { label: 'America/Montserrat', value: 'America/Montserrat' }, + { label: 'America/Nassau', value: 'America/Nassau' }, + { label: 'America/New_York', value: 'America/New_York' }, + { label: 'America/Nipigon', value: 'America/Nipigon' }, + { label: 'America/Nome', value: 'America/Nome' }, + { label: 'America/Noronha', value: 'America/Noronha' }, + { + label: 'America/North_Dakota/Beulah', + value: 'America/North_Dakota/Beulah', + }, + { + label: 'America/North_Dakota/Center', + value: 'America/North_Dakota/Center', + }, + { + label: 'America/North_Dakota/New_Salem', + value: 'America/North_Dakota/New_Salem', + }, + { label: 'America/Ojinaga', value: 'America/Ojinaga' }, + { label: 'America/Panama', value: 'America/Panama' }, + { label: 'America/Pangnirtung', value: 'America/Pangnirtung' }, + { label: 'America/Paramaribo', value: 'America/Paramaribo' }, + { label: 'America/Phoenix', value: 'America/Phoenix' }, + { label: 'America/Port-au-Prince', value: 'America/Port-au-Prince' }, + { label: 'America/Port_of_Spain', value: 'America/Port_of_Spain' }, + { label: 'America/Porto_Velho', value: 'America/Porto_Velho' }, + { label: 'America/Puerto_Rico', value: 'America/Puerto_Rico' }, + { label: 'America/Punta_Arenas', value: 'America/Punta_Arenas' }, + { label: 'America/Rainy_River', value: 'America/Rainy_River' }, + { label: 'America/Rankin_Inlet', value: 'America/Rankin_Inlet' }, + { label: 'America/Recife', value: 'America/Recife' }, + { label: 'America/Regina', value: 'America/Regina' }, + { label: 'America/Resolute', value: 'America/Resolute' }, + { label: 'America/Rio_Branco', value: 'America/Rio_Branco' }, + { label: 'America/Santa_Isabel', value: 'America/Santa_Isabel' }, + { label: 'America/Santarem', value: 'America/Santarem' }, + { label: 'America/Santiago', value: 'America/Santiago' }, + { label: 'America/Santo_Domingo', value: 'America/Santo_Domingo' }, + { label: 'America/Sao_Paulo', value: 'America/Sao_Paulo' }, + { label: 'America/Scoresbysund', value: 'America/Scoresbysund' }, + { label: 'America/Sitka', value: 'America/Sitka' }, + { label: 'America/St_Barthelemy', value: 'America/St_Barthelemy' }, + { label: 'America/St_Johns', value: 'America/St_Johns' }, + { label: 'America/St_Kitts', value: 'America/St_Kitts' }, + { label: 'America/St_Lucia', value: 'America/St_Lucia' }, + { label: 'America/St_Thomas', value: 'America/St_Thomas' }, + { label: 'America/St_Vincent', value: 'America/St_Vincent' }, + { label: 'America/Swift_Current', value: 'America/Swift_Current' }, + { label: 'America/Tegucigalpa', value: 'America/Tegucigalpa' }, + { label: 'America/Thule', value: 'America/Thule' }, + { label: 'America/Thunder_Bay', value: 'America/Thunder_Bay' }, + { label: 'America/Tijuana', value: 'America/Tijuana' }, + { label: 'America/Toronto', value: 'America/Toronto' }, + { label: 'America/Tortola', value: 'America/Tortola' }, + { label: 'America/Vancouver', value: 'America/Vancouver' }, + { label: 'America/Whitehorse', value: 'America/Whitehorse' }, + { label: 'America/Winnipeg', value: 'America/Winnipeg' }, + { label: 'America/Yakutat', value: 'America/Yakutat' }, + { label: 'America/Yellowknife', value: 'America/Yellowknife' }, + { label: 'Antarctica/Casey', value: 'Antarctica/Casey' }, + { label: 'Antarctica/Davis', value: 'Antarctica/Davis' }, + { label: 'Antarctica/DumontDUrville', value: 'Antarctica/DumontDUrville' }, + { label: 'Antarctica/Macquarie', value: 'Antarctica/Macquarie' }, + { label: 'Antarctica/Mawson', value: 'Antarctica/Mawson' }, + { label: 'Antarctica/McMurdo', value: 'Antarctica/McMurdo' }, + { label: 'Antarctica/Palmer', value: 'Antarctica/Palmer' }, + { label: 'Antarctica/Rothera', value: 'Antarctica/Rothera' }, + { label: 'Antarctica/Syowa', value: 'Antarctica/Syowa' }, + { label: 'Antarctica/Troll', value: 'Antarctica/Troll' }, + { label: 'Antarctica/Vostok', value: 'Antarctica/Vostok' }, + { label: 'Arctic/Longyearbyen', value: 'Arctic/Longyearbyen' }, + { label: 'Asia/Aden', value: 'Asia/Aden' }, + { label: 'Asia/Almaty', value: 'Asia/Almaty' }, + { label: 'Asia/Amman', value: 'Asia/Amman' }, + { label: 'Asia/Anadyr', value: 'Asia/Anadyr' }, + { label: 'Asia/Aqtau', value: 'Asia/Aqtau' }, + { label: 'Asia/Aqtobe', value: 'Asia/Aqtobe' }, + { label: 'Asia/Ashgabat', value: 'Asia/Ashgabat' }, + { label: 'Asia/Atyrau', value: 'Asia/Atyrau' }, + { label: 'Asia/Baghdad', value: 'Asia/Baghdad' }, + { label: 'Asia/Bahrain', value: 'Asia/Bahrain' }, + { label: 'Asia/Baku', value: 'Asia/Baku' }, + { label: 'Asia/Bangkok', value: 'Asia/Bangkok' }, + { label: 'Asia/Barnaul', value: 'Asia/Barnaul' }, + { label: 'Asia/Beirut', value: 'Asia/Beirut' }, + { label: 'Asia/Bishkek', value: 'Asia/Bishkek' }, + { label: 'Asia/Brunei', value: 'Asia/Brunei' }, + { label: 'Asia/Calcutta', value: 'Asia/Calcutta' }, + { label: 'Asia/Chita', value: 'Asia/Chita' }, + { label: 'Asia/Choibalsan', value: 'Asia/Choibalsan' }, + { label: 'Asia/Colombo', value: 'Asia/Colombo' }, + { label: 'Asia/Damascus', value: 'Asia/Damascus' }, + { label: 'Asia/Dhaka', value: 'Asia/Dhaka' }, + { label: 'Asia/Dili', value: 'Asia/Dili' }, + { label: 'Asia/Dubai', value: 'Asia/Dubai' }, + { label: 'Asia/Dushanbe', value: 'Asia/Dushanbe' }, + { label: 'Asia/Famagusta', value: 'Asia/Famagusta' }, + { label: 'Asia/Gaza', value: 'Asia/Gaza' }, + { label: 'Asia/Hebron', value: 'Asia/Hebron' }, + { label: 'Asia/Hong_Kong', value: 'Asia/Hong_Kong' }, + { label: 'Asia/Hovd', value: 'Asia/Hovd' }, + { label: 'Asia/Irkutsk', value: 'Asia/Irkutsk' }, + { label: 'Asia/Jakarta', value: 'Asia/Jakarta' }, + { label: 'Asia/Jayapura', value: 'Asia/Jayapura' }, + { label: 'Asia/Jerusalem', value: 'Asia/Jerusalem' }, + { label: 'Asia/Kabul', value: 'Asia/Kabul' }, + { label: 'Asia/Kamchatka', value: 'Asia/Kamchatka' }, + { label: 'Asia/Karachi', value: 'Asia/Karachi' }, + { label: 'Asia/Katmandu', value: 'Asia/Katmandu' }, + { label: 'Asia/Khandyga', value: 'Asia/Khandyga' }, + { label: 'Asia/Krasnoyarsk', value: 'Asia/Krasnoyarsk' }, + { label: 'Asia/Kuala_Lumpur', value: 'Asia/Kuala_Lumpur' }, + { label: 'Asia/Kuching', value: 'Asia/Kuching' }, + { label: 'Asia/Kuwait', value: 'Asia/Kuwait' }, + { label: 'Asia/Macau', value: 'Asia/Macau' }, + { label: 'Asia/Magadan', value: 'Asia/Magadan' }, + { label: 'Asia/Makassar', value: 'Asia/Makassar' }, + { label: 'Asia/Manila', value: 'Asia/Manila' }, + { label: 'Asia/Muscat', value: 'Asia/Muscat' }, + { label: 'Asia/Nicosia', value: 'Asia/Nicosia' }, + { label: 'Asia/Novokuznetsk', value: 'Asia/Novokuznetsk' }, + { label: 'Asia/Novosibirsk', value: 'Asia/Novosibirsk' }, + { label: 'Asia/Omsk', value: 'Asia/Omsk' }, + { label: 'Asia/Oral', value: 'Asia/Oral' }, + { label: 'Asia/Phnom_Penh', value: 'Asia/Phnom_Penh' }, + { label: 'Asia/Pontianak', value: 'Asia/Pontianak' }, + { label: 'Asia/Pyongyang', value: 'Asia/Pyongyang' }, + { label: 'Asia/Qatar', value: 'Asia/Qatar' }, + { label: 'Asia/Qostanay', value: 'Asia/Qostanay' }, + { label: 'Asia/Qyzylorda', value: 'Asia/Qyzylorda' }, + { label: 'Asia/Rangoon', value: 'Asia/Rangoon' }, + { label: 'Asia/Riyadh', value: 'Asia/Riyadh' }, + { label: 'Asia/Saigon', value: 'Asia/Saigon' }, + { label: 'Asia/Sakhalin', value: 'Asia/Sakhalin' }, + { label: 'Asia/Samarkand', value: 'Asia/Samarkand' }, + { label: 'Asia/Seoul', value: 'Asia/Seoul' }, + { label: 'Asia/Shanghai', value: 'Asia/Shanghai' }, + { label: 'Asia/Singapore', value: 'Asia/Singapore' }, + { label: 'Asia/Srednekolymsk', value: 'Asia/Srednekolymsk' }, + { label: 'Asia/Taipei', value: 'Asia/Taipei' }, + { label: 'Asia/Tashkent', value: 'Asia/Tashkent' }, + { label: 'Asia/Tbilisi', value: 'Asia/Tbilisi' }, + { label: 'Asia/Tehran', value: 'Asia/Tehran' }, + { label: 'Asia/Thimphu', value: 'Asia/Thimphu' }, + { label: 'Asia/Tokyo', value: 'Asia/Tokyo' }, + { label: 'Asia/Tomsk', value: 'Asia/Tomsk' }, + { label: 'Asia/Ulaanbaatar', value: 'Asia/Ulaanbaatar' }, + { label: 'Asia/Urumqi', value: 'Asia/Urumqi' }, + { label: 'Asia/Ust-Nera', value: 'Asia/Ust-Nera' }, + { label: 'Asia/Vientiane', value: 'Asia/Vientiane' }, + { label: 'Asia/Vladivostok', value: 'Asia/Vladivostok' }, + { label: 'Asia/Yakutsk', value: 'Asia/Yakutsk' }, + { label: 'Asia/Yekaterinburg', value: 'Asia/Yekaterinburg' }, + { label: 'Asia/Yerevan', value: 'Asia/Yerevan' }, + { label: 'Atlantic/Azores', value: 'Atlantic/Azores' }, + { label: 'Atlantic/Bermuda', value: 'Atlantic/Bermuda' }, + { label: 'Atlantic/Canary', value: 'Atlantic/Canary' }, + { label: 'Atlantic/Cape_Verde', value: 'Atlantic/Cape_Verde' }, + { label: 'Atlantic/Faeroe', value: 'Atlantic/Faeroe' }, + { label: 'Atlantic/Madeira', value: 'Atlantic/Madeira' }, + { label: 'Atlantic/Reykjavik', value: 'Atlantic/Reykjavik' }, + { label: 'Atlantic/South_Georgia', value: 'Atlantic/South_Georgia' }, + { label: 'Atlantic/St_Helena', value: 'Atlantic/St_Helena' }, + { label: 'Atlantic/Stanley', value: 'Atlantic/Stanley' }, + { label: 'Australia/Adelaide', value: 'Australia/Adelaide' }, + { label: 'Australia/Brisbane', value: 'Australia/Brisbane' }, + { label: 'Australia/Broken_Hill', value: 'Australia/Broken_Hill' }, + { label: 'Australia/Currie', value: 'Australia/Currie' }, + { label: 'Australia/Darwin', value: 'Australia/Darwin' }, + { label: 'Australia/Eucla', value: 'Australia/Eucla' }, + { label: 'Australia/Hobart', value: 'Australia/Hobart' }, + { label: 'Australia/Lindeman', value: 'Australia/Lindeman' }, + { label: 'Australia/Lord_Howe', value: 'Australia/Lord_Howe' }, + { label: 'Australia/Melbourne', value: 'Australia/Melbourne' }, + { label: 'Australia/Perth', value: 'Australia/Perth' }, + { label: 'Australia/Sydney', value: 'Australia/Sydney' }, + { label: 'Europe/Amsterdam', value: 'Europe/Amsterdam' }, + { label: 'Europe/Andorra', value: 'Europe/Andorra' }, + { label: 'Europe/Astrakhan', value: 'Europe/Astrakhan' }, + { label: 'Europe/Athens', value: 'Europe/Athens' }, + { label: 'Europe/Belgrade', value: 'Europe/Belgrade' }, + { label: 'Europe/Berlin', value: 'Europe/Berlin' }, + { label: 'Europe/Bratislava', value: 'Europe/Bratislava' }, + { label: 'Europe/Brussels', value: 'Europe/Brussels' }, + { label: 'Europe/Bucharest', value: 'Europe/Bucharest' }, + { label: 'Europe/Budapest', value: 'Europe/Budapest' }, + { label: 'Europe/Busingen', value: 'Europe/Busingen' }, + { label: 'Europe/Chisinau', value: 'Europe/Chisinau' }, + { label: 'Europe/Copenhagen', value: 'Europe/Copenhagen' }, + { label: 'Europe/Dublin', value: 'Europe/Dublin' }, + { label: 'Europe/Gibraltar', value: 'Europe/Gibraltar' }, + { label: 'Europe/Guernsey', value: 'Europe/Guernsey' }, + { label: 'Europe/Helsinki', value: 'Europe/Helsinki' }, + { label: 'Europe/Isle_of_Man', value: 'Europe/Isle_of_Man' }, + { label: 'Europe/Istanbul', value: 'Europe/Istanbul' }, + { label: 'Europe/Jersey', value: 'Europe/Jersey' }, + { label: 'Europe/Kaliningrad', value: 'Europe/Kaliningrad' }, + { label: 'Europe/Kiev', value: 'Europe/Kiev' }, + { label: 'Europe/Kirov', value: 'Europe/Kirov' }, + { label: 'Europe/Lisbon', value: 'Europe/Lisbon' }, + { label: 'Europe/Ljubljana', value: 'Europe/Ljubljana' }, + { label: 'Europe/London', value: 'Europe/London' }, + { label: 'Europe/Luxembourg', value: 'Europe/Luxembourg' }, + { label: 'Europe/Madrid', value: 'Europe/Madrid' }, + { label: 'Europe/Malta', value: 'Europe/Malta' }, + { label: 'Europe/Mariehamn', value: 'Europe/Mariehamn' }, + { label: 'Europe/Minsk', value: 'Europe/Minsk' }, + { label: 'Europe/Monaco', value: 'Europe/Monaco' }, + { label: 'Europe/Moscow', value: 'Europe/Moscow' }, + { label: 'Europe/Oslo', value: 'Europe/Oslo' }, + { label: 'Europe/Paris', value: 'Europe/Paris' }, + { label: 'Europe/Podgorica', value: 'Europe/Podgorica' }, + { label: 'Europe/Prague', value: 'Europe/Prague' }, + { label: 'Europe/Riga', value: 'Europe/Riga' }, + { label: 'Europe/Rome', value: 'Europe/Rome' }, + { label: 'Europe/Samara', value: 'Europe/Samara' }, + { label: 'Europe/San_Marino', value: 'Europe/San_Marino' }, + { label: 'Europe/Sarajevo', value: 'Europe/Sarajevo' }, + { label: 'Europe/Saratov', value: 'Europe/Saratov' }, + { label: 'Europe/Simferopol', value: 'Europe/Simferopol' }, + { label: 'Europe/Skopje', value: 'Europe/Skopje' }, + { label: 'Europe/Sofia', value: 'Europe/Sofia' }, + { label: 'Europe/Stockholm', value: 'Europe/Stockholm' }, + { label: 'Europe/Tallinn', value: 'Europe/Tallinn' }, + { label: 'Europe/Tirane', value: 'Europe/Tirane' }, + { label: 'Europe/Ulyanovsk', value: 'Europe/Ulyanovsk' }, + { label: 'Europe/Uzhgorod', value: 'Europe/Uzhgorod' }, + { label: 'Europe/Vaduz', value: 'Europe/Vaduz' }, + { label: 'Europe/Vatican', value: 'Europe/Vatican' }, + { label: 'Europe/Vienna', value: 'Europe/Vienna' }, + { label: 'Europe/Vilnius', value: 'Europe/Vilnius' }, + { label: 'Europe/Volgograd', value: 'Europe/Volgograd' }, + { label: 'Europe/Warsaw', value: 'Europe/Warsaw' }, + { label: 'Europe/Zagreb', value: 'Europe/Zagreb' }, + { label: 'Europe/Zaporozhye', value: 'Europe/Zaporozhye' }, + { label: 'Europe/Zurich', value: 'Europe/Zurich' }, + { label: 'Indian/Antananarivo', value: 'Indian/Antananarivo' }, + { label: 'Indian/Chagos', value: 'Indian/Chagos' }, + { label: 'Indian/Christmas', value: 'Indian/Christmas' }, + { label: 'Indian/Cocos', value: 'Indian/Cocos' }, + { label: 'Indian/Comoro', value: 'Indian/Comoro' }, + { label: 'Indian/Kerguelen', value: 'Indian/Kerguelen' }, + { label: 'Indian/Mahe', value: 'Indian/Mahe' }, + { label: 'Indian/Maldives', value: 'Indian/Maldives' }, + { label: 'Indian/Mauritius', value: 'Indian/Mauritius' }, + { label: 'Indian/Mayotte', value: 'Indian/Mayotte' }, + { label: 'Indian/Reunion', value: 'Indian/Reunion' }, + { label: 'Pacific/Apia', value: 'Pacific/Apia' }, + { label: 'Pacific/Auckland', value: 'Pacific/Auckland' }, + { label: 'Pacific/Bougainville', value: 'Pacific/Bougainville' }, + { label: 'Pacific/Chatham', value: 'Pacific/Chatham' }, + { label: 'Pacific/Easter', value: 'Pacific/Easter' }, + { label: 'Pacific/Efate', value: 'Pacific/Efate' }, + { label: 'Pacific/Enderbury', value: 'Pacific/Enderbury' }, + { label: 'Pacific/Fakaofo', value: 'Pacific/Fakaofo' }, + { label: 'Pacific/Fiji', value: 'Pacific/Fiji' }, + { label: 'Pacific/Funafuti', value: 'Pacific/Funafuti' }, + { label: 'Pacific/Galapagos', value: 'Pacific/Galapagos' }, + { label: 'Pacific/Gambier', value: 'Pacific/Gambier' }, + { label: 'Pacific/Guadalcanal', value: 'Pacific/Guadalcanal' }, + { label: 'Pacific/Guam', value: 'Pacific/Guam' }, + { label: 'Pacific/Honolulu', value: 'Pacific/Honolulu' }, + { label: 'Pacific/Johnston', value: 'Pacific/Johnston' }, + { label: 'Pacific/Kiritimati', value: 'Pacific/Kiritimati' }, + { label: 'Pacific/Kosrae', value: 'Pacific/Kosrae' }, + { label: 'Pacific/Kwajalein', value: 'Pacific/Kwajalein' }, + { label: 'Pacific/Majuro', value: 'Pacific/Majuro' }, + { label: 'Pacific/Marquesas', value: 'Pacific/Marquesas' }, + { label: 'Pacific/Midway', value: 'Pacific/Midway' }, + { label: 'Pacific/Nauru', value: 'Pacific/Nauru' }, + { label: 'Pacific/Niue', value: 'Pacific/Niue' }, + { label: 'Pacific/Norfolk', value: 'Pacific/Norfolk' }, + { label: 'Pacific/Noumea', value: 'Pacific/Noumea' }, + { label: 'Pacific/Pago_Pago', value: 'Pacific/Pago_Pago' }, + { label: 'Pacific/Palau', value: 'Pacific/Palau' }, + { label: 'Pacific/Pitcairn', value: 'Pacific/Pitcairn' }, + { label: 'Pacific/Ponape', value: 'Pacific/Ponape' }, + { label: 'Pacific/Port_Moresby', value: 'Pacific/Port_Moresby' }, + { label: 'Pacific/Rarotonga', value: 'Pacific/Rarotonga' }, + { label: 'Pacific/Saipan', value: 'Pacific/Saipan' }, + { label: 'Pacific/Tahiti', value: 'Pacific/Tahiti' }, + { label: 'Pacific/Tarawa', value: 'Pacific/Tarawa' }, + { label: 'Pacific/Tongatapu', value: 'Pacific/Tongatapu' }, + { label: 'Pacific/Truk', value: 'Pacific/Truk' }, + { label: 'Pacific/Wake', value: 'Pacific/Wake' }, + { label: 'Pacific/Wallis', value: 'Pacific/Wallis' }, +]; + +export default timezoneOptions; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/index.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/index.js new file mode 100644 index 0000000..a9e4825 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/index.js @@ -0,0 +1,52 @@ +import base64ToString from './text/base64-to-string.js'; +import capitalize from './text/capitalize.js'; +import encodeUriComponent from './text/encode-uri-component.js'; +import extractEmailAddress from './text/extract-email-address.js'; +import extractNumber from './text/extract-number.js'; +import htmlToMarkdown from './text/html-to-markdown.js'; +import lowercase from './text/lowercase.js'; +import markdownToHtml from './text/markdown-to-html.js'; +import pluralize from './text/pluralize.js'; +import replace from './text/replace.js'; +import stringToBase64 from './text/string-to-base64.js'; +import encodeUri from './text/encode-uri.js'; +import trimWhitespace from './text/trim-whitespace.js'; +import useDefaultValue from './text/use-default-value.js'; +import parseStringifiedJson from './text/parse-stringified-json.js'; +import performMathOperation from './numbers/perform-math-operation.js'; +import randomNumber from './numbers/random-number.js'; +import formatNumber from './numbers/format-number.js'; +import formatPhoneNumber from './numbers/format-phone-number.js'; +import formatDateTime from './date-time/format-date-time.js'; + +const options = { + base64ToString, + capitalize, + encodeUriComponent, + extractEmailAddress, + extractNumber, + htmlToMarkdown, + lowercase, + markdownToHtml, + pluralize, + replace, + stringToBase64, + encodeUri, + trimWhitespace, + useDefaultValue, + performMathOperation, + randomNumber, + formatNumber, + formatPhoneNumber, + formatDateTime, + parseStringifiedJson, +}; + +export default { + name: 'List fields after transform', + key: 'listTransformOptions', + + async run($) { + return options[$.step.parameters.transform]; + }, +}; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-number.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-number.js new file mode 100644 index 0000000..9fca34f --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-number.js @@ -0,0 +1,38 @@ +const formatNumber = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'The number you want to format.', + variables: true, + }, + { + label: 'Input Decimal Mark', + key: 'inputDecimalMark', + type: 'dropdown', + required: true, + description: 'The decimal mark of the input number.', + variables: true, + options: [ + { label: 'Comma', value: ',' }, + { label: 'Period', value: '.' }, + ], + }, + { + label: 'To Format', + key: 'toFormat', + type: 'dropdown', + required: true, + description: 'The format you want to convert the number to.', + variables: true, + options: [ + { label: 'Comma for grouping & period for decimal', value: '0' }, + { label: 'Period for grouping & comma for decimal', value: '1' }, + { label: 'Space for grouping & period for decimal', value: '2' }, + { label: 'Space for grouping & comma for decimal', value: '3' }, + ], + }, +]; + +export default formatNumber; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-phone-number.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-phone-number.js new file mode 100644 index 0000000..c96b126 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/format-phone-number.js @@ -0,0 +1,36 @@ +import phoneNumberCountryCodes from '../../../common/phone-number-country-codes.js'; + +const formatPhoneNumber = [ + { + label: 'Phone Number', + key: 'phoneNumber', + type: 'string', + required: true, + description: 'The phone number you want to format.', + variables: true, + }, + { + label: 'To Format', + key: 'toFormat', + type: 'dropdown', + required: true, + description: 'The format you want to convert the number to.', + variables: true, + options: [ + { label: '+491632223344 (E164)', value: 'e164' }, + { label: '+49 163 2223344 (International)', value: 'international' }, + { label: '0163 2223344 (National)', value: 'national' }, + ], + }, + { + label: 'Phone Number Country Code', + key: 'phoneNumberCountryCode', + type: 'dropdown', + required: true, + description: 'The country code of the phone number. The default is US.', + variables: true, + options: phoneNumberCountryCodes, + }, +]; + +export default formatPhoneNumber; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/perform-math-operation.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/perform-math-operation.js new file mode 100644 index 0000000..85378bb --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/perform-math-operation.js @@ -0,0 +1,36 @@ +const performMathOperation = [ + { + label: 'Math Operation', + key: 'mathOperation', + type: 'dropdown', + required: true, + description: 'The math operation to perform.', + variables: true, + options: [ + { label: 'Add', value: 'add' }, + { label: 'Divide', value: 'divide' }, + { label: 'Make Negative', value: 'makeNegative' }, + { label: 'Multiply', value: 'multiply' }, + { label: 'Subtract', value: 'subtract' }, + ], + }, + { + label: 'Values', + key: 'values', + type: 'dynamic', + required: false, + description: 'Add or remove numbers as needed.', + fields: [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'The number to perform the math operation on.', + variables: true, + }, + ], + }, +]; + +export default performMathOperation; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/random-number.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/random-number.js new file mode 100644 index 0000000..ba3d714 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/numbers/random-number.js @@ -0,0 +1,29 @@ +const randomNumber = [ + { + label: 'Lower range', + key: 'lowerRange', + type: 'string', + required: true, + description: 'The lowest number to generate.', + variables: true, + }, + { + label: 'Upper range', + key: 'upperRange', + type: 'string', + required: true, + description: 'The highest number to generate.', + variables: true, + }, + { + label: 'Decimal points', + key: 'decimalPoints', + type: 'string', + required: false, + description: + 'The number of digits after the decimal point. It can be an integer between 0 and 15.', + variables: true, + }, +]; + +export default randomNumber; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/base64-to-string.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/base64-to-string.js new file mode 100644 index 0000000..f96def8 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/base64-to-string.js @@ -0,0 +1,12 @@ +const base64ToString = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be converted from Base64 to string.', + variables: true, + }, +]; + +export default base64ToString; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/capitalize.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/capitalize.js new file mode 100644 index 0000000..523d8b0 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/capitalize.js @@ -0,0 +1,12 @@ +const capitalize = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be capitalized.', + variables: true, + }, +]; + +export default capitalize; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri-component.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri-component.js new file mode 100644 index 0000000..3d6a183 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri-component.js @@ -0,0 +1,12 @@ +const encodeUriComponent = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'URI Component to encode', + variables: true, + }, +]; + +export default encodeUriComponent; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri.js new file mode 100644 index 0000000..6ee02fc --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/encode-uri.js @@ -0,0 +1,12 @@ +const encodeUri = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'URI to encode', + variables: true, + }, +]; + +export default encodeUri; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-email-address.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-email-address.js new file mode 100644 index 0000000..9f0f5d8 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-email-address.js @@ -0,0 +1,12 @@ +const extractEmailAddress = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be searched for an email address.', + variables: true, + }, +]; + +export default extractEmailAddress; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-number.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-number.js new file mode 100644 index 0000000..2fe0ba6 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/extract-number.js @@ -0,0 +1,12 @@ +const extractNumber = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be searched for a number.', + variables: true, + }, +]; + +export default extractNumber; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/html-to-markdown.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/html-to-markdown.js new file mode 100644 index 0000000..77b0ba9 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/html-to-markdown.js @@ -0,0 +1,12 @@ +const htmlToMarkdown = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'HTML that will be converted to Markdown.', + variables: true, + }, +]; + +export default htmlToMarkdown; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/lowercase.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/lowercase.js new file mode 100644 index 0000000..d37eb2e --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/lowercase.js @@ -0,0 +1,12 @@ +const lowercase = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be lowercased.', + variables: true, + }, +]; + +export default lowercase; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/markdown-to-html.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/markdown-to-html.js new file mode 100644 index 0000000..ad17ed7 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/markdown-to-html.js @@ -0,0 +1,12 @@ +const markdownToHtml = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Markdown text that will be converted to HTML.', + variables: true, + }, +]; + +export default markdownToHtml; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/parse-stringified-json.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/parse-stringified-json.js new file mode 100644 index 0000000..e4de0ed --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/parse-stringified-json.js @@ -0,0 +1,12 @@ +const useDefaultValue = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Stringified JSON you want to parse.', + variables: true, + }, +]; + +export default useDefaultValue; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/pluralize.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/pluralize.js new file mode 100644 index 0000000..36e5df3 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/pluralize.js @@ -0,0 +1,12 @@ +const pluralize = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be pluralized.', + variables: true, + }, +]; + +export default pluralize; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/replace.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/replace.js new file mode 100644 index 0000000..835e9ee --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/replace.js @@ -0,0 +1,55 @@ +const replace = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that you want to search for and replace values.', + variables: true, + }, + { + label: 'Find', + key: 'find', + type: 'string', + required: true, + description: 'Text that will be searched for.', + variables: true, + }, + { + label: 'Replace', + key: 'replace', + type: 'string', + required: false, + description: 'Text that will replace the found text.', + variables: true, + }, + { + label: 'Use Regular Expression', + key: 'useRegex', + type: 'dropdown', + required: true, + description: 'Use regex to search values.', + variables: true, + value: false, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listReplaceRegexOptions', + }, + { + name: 'parameters.useRegex', + value: '{parameters.useRegex}', + }, + ], + }, + }, +]; + +export default replace; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/string-to-base64.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/string-to-base64.js new file mode 100644 index 0000000..1fb5695 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/string-to-base64.js @@ -0,0 +1,12 @@ +const stringToBase64 = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text that will be converted to Base64.', + variables: true, + }, +]; + +export default stringToBase64; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/trim-whitespace.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/trim-whitespace.js new file mode 100644 index 0000000..538cb26 --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/trim-whitespace.js @@ -0,0 +1,12 @@ +const trimWhitespace = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text you want to remove leading and trailing spaces.', + variables: true, + }, +]; + +export default trimWhitespace; diff --git a/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/use-default-value.js b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/use-default-value.js new file mode 100644 index 0000000..e2edc1f --- /dev/null +++ b/packages/backend/src/apps/formatter/dynamic-fields/list-transform-options/text/use-default-value.js @@ -0,0 +1,21 @@ +const useDefaultValue = [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'Text you want to check whether it is empty or not.', + variables: true, + }, + { + label: 'Default Value', + key: 'defaultValue', + type: 'string', + required: true, + description: + 'Text that will be used as a default value if the input is empty.', + variables: true, + }, +]; + +export default useDefaultValue; diff --git a/packages/backend/src/apps/formatter/index.js b/packages/backend/src/apps/formatter/index.js new file mode 100644 index 0000000..9ae22b8 --- /dev/null +++ b/packages/backend/src/apps/formatter/index.js @@ -0,0 +1,16 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'Formatter', + key: 'formatter', + iconUrl: '{BASE_URL}/apps/formatter/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/formatter/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#001F52', + actions, + dynamicFields, +}); diff --git a/packages/backend/src/apps/freescout/assets/favicon.svg b/packages/backend/src/apps/freescout/assets/favicon.svg new file mode 100644 index 0000000..db766fa --- /dev/null +++ b/packages/backend/src/apps/freescout/assets/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/packages/backend/src/apps/freescout/auth/index.js b/packages/backend/src/apps/freescout/auth/index.js new file mode 100644 index 0000000..f6c8aed --- /dev/null +++ b/packages/backend/src/apps/freescout/auth/index.js @@ -0,0 +1,44 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'instanceUrl', + label: 'FreeScout instance URL', + type: 'string', + required: true, + readOnly: false, + description: 'Your FreeScout instance URL.', + docUrl: 'https://automatisch.io/docs/freescout#instance-url', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'FreeScout API key of your account.', + docUrl: 'https://automatisch.io/docs/freescout#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/freescout/auth/is-still-verified.js b/packages/backend/src/apps/freescout/auth/is-still-verified.js new file mode 100644 index 0000000..c551491 --- /dev/null +++ b/packages/backend/src/apps/freescout/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/api/mailboxes'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/freescout/auth/verify-credentials.js b/packages/backend/src/apps/freescout/auth/verify-credentials.js new file mode 100644 index 0000000..1ac858a --- /dev/null +++ b/packages/backend/src/apps/freescout/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/api/mailboxes'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/freescout/common/add-auth-header.js b/packages/backend/src/apps/freescout/common/add-auth-header.js new file mode 100644 index 0000000..e599ae4 --- /dev/null +++ b/packages/backend/src/apps/freescout/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['X-FreeScout-API-Key'] = $.auth.data.apiKey; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/freescout/common/set-base-url.js b/packages/backend/src/apps/freescout/common/set-base-url.js new file mode 100644 index 0000000..8df6a36 --- /dev/null +++ b/packages/backend/src/apps/freescout/common/set-base-url.js @@ -0,0 +1,6 @@ +const setBaseUrl = ($, requestConfig) => { + requestConfig.baseURL = $.auth.data.instanceUrl; + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/freescout/common/webhook-filters.js b/packages/backend/src/apps/freescout/common/webhook-filters.js new file mode 100644 index 0000000..00602d9 --- /dev/null +++ b/packages/backend/src/apps/freescout/common/webhook-filters.js @@ -0,0 +1,52 @@ +const webhookFilters = [ + { + value: 'convo.assigned', + label: 'Conversation assigned', + }, + { + value: 'convo.created', + label: 'Conversation created', + }, + { + value: 'convo.deleted', + label: 'Conversation deleted', + }, + { + value: 'convo.deleted_forever', + label: 'Conversation deleted forever', + }, + { + value: 'convo.restored', + label: 'Conversation restored from Deleted folder', + }, + { + value: 'convo.moved', + label: 'Conversation moved', + }, + { + value: 'convo.status', + label: 'Conversation status updated', + }, + { + value: 'convo.customer.reply.created', + label: 'Customer replied', + }, + { + value: 'convo.agent.reply.created', + label: 'Agent replied', + }, + { + value: 'convo.note.created', + label: 'Note added', + }, + { + value: 'customer.created', + label: 'Customer create', + }, + { + value: 'customer.updated', + label: 'Customer update', + }, +]; + +export default webhookFilters; diff --git a/packages/backend/src/apps/freescout/index.js b/packages/backend/src/apps/freescout/index.js new file mode 100644 index 0000000..4044522 --- /dev/null +++ b/packages/backend/src/apps/freescout/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import setBaseUrl from './common/set-base-url.js'; + +export default defineApp({ + name: 'FreeScout', + key: 'freescout', + iconUrl: '{BASE_URL}/apps/freescout/assets/favicon.svg', + supportsConnections: true, + baseUrl: 'https://freescout.net', + primaryColor: '#F5D05E', + authDocUrl: '{DOCS_URL}/apps/freescout/connection', + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/freescout/triggers/index.js b/packages/backend/src/apps/freescout/triggers/index.js new file mode 100644 index 0000000..0a362fd --- /dev/null +++ b/packages/backend/src/apps/freescout/triggers/index.js @@ -0,0 +1,3 @@ +import newEvent from './new-event/index.js'; + +export default [newEvent]; diff --git a/packages/backend/src/apps/freescout/triggers/new-event/index.js b/packages/backend/src/apps/freescout/triggers/new-event/index.js new file mode 100644 index 0000000..caae12e --- /dev/null +++ b/packages/backend/src/apps/freescout/triggers/new-event/index.js @@ -0,0 +1,61 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; +import webhookFilters from '../../common/webhook-filters.js'; + +export default defineTrigger({ + name: 'New event', + key: 'newEvent', + type: 'webhook', + description: 'Triggers when a new event occurs.', + arguments: [ + { + label: 'Event type', + key: 'eventType', + type: 'dropdown', + required: true, + description: 'Pick an event type to receive events for.', + variables: false, + options: webhookFilters, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const payload = { + url: $.webhookUrl, + events: [$.step.parameters.eventType], + }; + + const response = await $.http.post('/api/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data?.id?.toString()); + }, + + async unregisterHook($) { + await $.http.delete(`/api/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/ghost/assets/favicon.svg b/packages/backend/src/apps/ghost/assets/favicon.svg new file mode 100644 index 0000000..e98fb6f --- /dev/null +++ b/packages/backend/src/apps/ghost/assets/favicon.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + diff --git a/packages/backend/src/apps/ghost/auth/index.js b/packages/backend/src/apps/ghost/auth/index.js new file mode 100644 index 0000000..c9399c0 --- /dev/null +++ b/packages/backend/src/apps/ghost/auth/index.js @@ -0,0 +1,32 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'instanceUrl', + label: 'Instance URL', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'Admin API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/ghost/auth/is-still-verified.js b/packages/backend/src/apps/ghost/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/ghost/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/ghost/auth/verify-credentials.js b/packages/backend/src/apps/ghost/auth/verify-credentials.js new file mode 100644 index 0000000..13e46b4 --- /dev/null +++ b/packages/backend/src/apps/ghost/auth/verify-credentials.js @@ -0,0 +1,14 @@ +const verifyCredentials = async ($) => { + const site = await $.http.get('/admin/site/'); + const screenName = [site.data.site.title, site.data.site.url] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + screenName, + }); + + await $.http.get('/admin/pages/'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/ghost/common/add-auth-header.js b/packages/backend/src/apps/ghost/common/add-auth-header.js new file mode 100644 index 0000000..60b65a2 --- /dev/null +++ b/packages/backend/src/apps/ghost/common/add-auth-header.js @@ -0,0 +1,22 @@ +import jwt from 'jsonwebtoken'; + +const addAuthHeader = ($, requestConfig) => { + const key = $.auth.data?.apiKey; + + if (key) { + const [id, secret] = key.split(':'); + + const token = jwt.sign({}, Buffer.from(secret, 'hex'), { + keyid: id, + algorithm: 'HS256', + expiresIn: '1h', + audience: `/admin/`, + }); + + requestConfig.headers.Authorization = `Ghost ${token}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/ghost/common/set-base-url.js b/packages/backend/src/apps/ghost/common/set-base-url.js new file mode 100644 index 0000000..a8f668d --- /dev/null +++ b/packages/backend/src/apps/ghost/common/set-base-url.js @@ -0,0 +1,10 @@ +const setBaseUrl = ($, requestConfig) => { + const instanceUrl = $.auth.data.instanceUrl; + if (instanceUrl) { + requestConfig.baseURL = `${instanceUrl}/ghost/api`; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/ghost/index.js b/packages/backend/src/apps/ghost/index.js new file mode 100644 index 0000000..4efd494 --- /dev/null +++ b/packages/backend/src/apps/ghost/index.js @@ -0,0 +1,19 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Ghost', + key: 'ghost', + baseUrl: 'https://ghost.org', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/ghost/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/ghost/connection', + primaryColor: '#15171A', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/ghost/triggers/index.js b/packages/backend/src/apps/ghost/triggers/index.js new file mode 100644 index 0000000..5505ef0 --- /dev/null +++ b/packages/backend/src/apps/ghost/triggers/index.js @@ -0,0 +1,3 @@ +import newPostPublished from './new-post-published/index.js'; + +export default [newPostPublished]; diff --git a/packages/backend/src/apps/ghost/triggers/new-post-published/index.js b/packages/backend/src/apps/ghost/triggers/new-post-published/index.js new file mode 100644 index 0000000..12ad9d9 --- /dev/null +++ b/packages/backend/src/apps/ghost/triggers/new-post-published/index.js @@ -0,0 +1,55 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New post published', + key: 'newPostPublished', + type: 'webhook', + description: 'Triggers when a new post is published.', + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const payload = { + webhooks: [ + { + event: 'post.published', + target_url: $.webhookUrl, + name: `Flow ID: ${$.flow.id}`, + }, + ], + }; + + const response = await $.http.post('/admin/webhooks/', payload); + const id = response.data.webhooks[0].id; + + await $.flow.setRemoteWebhookId(id); + }, + + async unregisterHook($) { + await $.http.delete(`/admin/webhooks/${$.flow.remoteWebhookId}/`); + }, +}); diff --git a/packages/backend/src/apps/github/actions/create-issue/index.js b/packages/backend/src/apps/github/actions/create-issue/index.js new file mode 100644 index 0000000..9182750 --- /dev/null +++ b/packages/backend/src/apps/github/actions/create-issue/index.js @@ -0,0 +1,58 @@ +import defineAction from '../../../../helpers/define-action.js'; +import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo.js'; + +export default defineAction({ + name: 'Create issue', + key: 'createIssue', + description: 'Creates a new issue.', + arguments: [ + { + label: 'Repo', + key: 'repo', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRepos', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Body', + key: 'body', + type: 'string', + required: true, + variables: true, + }, + ], + + async run($) { + const repoParameter = $.step.parameters.repo; + const title = $.step.parameters.title; + const body = $.step.parameters.body; + + if (!repoParameter) throw new Error('A repo must be set!'); + if (!title) throw new Error('A title must be set!'); + + const { repoOwner, repo } = getRepoOwnerAndRepo(repoParameter); + const response = await $.http.post(`/repos/${repoOwner}/${repo}/issues`, { + title, + body, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/github/actions/index.js b/packages/backend/src/apps/github/actions/index.js new file mode 100644 index 0000000..095990d --- /dev/null +++ b/packages/backend/src/apps/github/actions/index.js @@ -0,0 +1,3 @@ +import createIssue from './create-issue/index.js'; + +export default [createIssue]; diff --git a/packages/backend/src/apps/github/assets/favicon.svg b/packages/backend/src/apps/github/assets/favicon.svg new file mode 100644 index 0000000..8a75650 --- /dev/null +++ b/packages/backend/src/apps/github/assets/favicon.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/github/auth/generate-auth-url.js b/packages/backend/src/apps/github/auth/generate-auth-url.js new file mode 100644 index 0000000..3b84536 --- /dev/null +++ b/packages/backend/src/apps/github/auth/generate-auth-url.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const scopes = ['read:org', 'repo', 'user']; + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.consumerKey, + redirect_uri: redirectUri, + scope: scopes.join(','), + }); + + const url = `${ + $.app.baseUrl + }/login/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/github/auth/index.js b/packages/backend/src/apps/github/auth/index.js new file mode 100644 index 0000000..59731eb --- /dev/null +++ b/packages/backend/src/apps/github/auth/index.js @@ -0,0 +1,49 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/github/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Github OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/github#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/github#client-id', + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/github#client-secret', + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/github/auth/is-still-verified.js b/packages/backend/src/apps/github/auth/is-still-verified.js new file mode 100644 index 0000000..1b9d46d --- /dev/null +++ b/packages/backend/src/apps/github/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/github/auth/verify-credentials.js b/packages/backend/src/apps/github/auth/verify-credentials.js new file mode 100644 index 0000000..b07549b --- /dev/null +++ b/packages/backend/src/apps/github/auth/verify-credentials.js @@ -0,0 +1,35 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const response = await $.http.post( + `${$.app.baseUrl}/login/oauth/access_token`, + { + client_id: $.auth.data.consumerKey, + client_secret: $.auth.data.consumerSecret, + code: $.auth.data.code, + }, + { + headers: { + Accept: 'application/json', + }, + } + ); + + const data = response.data; + + $.auth.data.accessToken = data.access_token; + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + consumerKey: $.auth.data.consumerKey, + consumerSecret: $.auth.data.consumerSecret, + accessToken: data.access_token, + scope: data.scope, + tokenType: data.token_type, + userId: currentUser.id, + screenName: currentUser.login, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/github/common/add-auth-header.js b/packages/backend/src/apps/github/common/add-auth-header.js new file mode 100644 index 0000000..9d56bd1 --- /dev/null +++ b/packages/backend/src/apps/github/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.headers && $.auth.data?.accessToken) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/github/common/get-current-user.js b/packages/backend/src/apps/github/common/get-current-user.js new file mode 100644 index 0000000..f09b398 --- /dev/null +++ b/packages/backend/src/apps/github/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/user'); + + const currentUser = response.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/github/common/get-repo-owner-and-repo.js b/packages/backend/src/apps/github/common/get-repo-owner-and-repo.js new file mode 100644 index 0000000..ee00b24 --- /dev/null +++ b/packages/backend/src/apps/github/common/get-repo-owner-and-repo.js @@ -0,0 +1,10 @@ +export default function getRepoOwnerAndRepo(repoFullName) { + if (!repoFullName) return {}; + + const [repoOwner, repo] = repoFullName.split('/'); + + return { + repoOwner, + repo, + }; +} diff --git a/packages/backend/src/apps/github/common/paginate-all.js b/packages/backend/src/apps/github/common/paginate-all.js new file mode 100644 index 0000000..e609b2d --- /dev/null +++ b/packages/backend/src/apps/github/common/paginate-all.js @@ -0,0 +1,22 @@ +import parseLinkHeader from '../../../helpers/parse-header-link.js'; + +export default async function paginateAll($, request) { + const response = await request; + const aggregatedResponse = { + data: [...response.data], + }; + + let links = parseLinkHeader(response.headers.link); + + while (links.next) { + const nextPageResponse = await $.http.request({ + ...response.config, + url: links.next.uri, + }); + + aggregatedResponse.data.push(...nextPageResponse.data); + links = parseLinkHeader(nextPageResponse.headers.link); + } + + return aggregatedResponse; +} diff --git a/packages/backend/src/apps/github/dynamic-data/index.js b/packages/backend/src/apps/github/dynamic-data/index.js new file mode 100644 index 0000000..04afcbd --- /dev/null +++ b/packages/backend/src/apps/github/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listLabels from './list-labels/index.js'; +import listRepos from './list-repos/index.js'; + +export default [listLabels, listRepos]; diff --git a/packages/backend/src/apps/github/dynamic-data/list-labels/index.js b/packages/backend/src/apps/github/dynamic-data/list-labels/index.js new file mode 100644 index 0000000..d9ab38b --- /dev/null +++ b/packages/backend/src/apps/github/dynamic-data/list-labels/index.js @@ -0,0 +1,25 @@ +import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo.js'; +import paginateAll from '../../common/paginate-all.js'; + +export default { + name: 'List labels', + key: 'listLabels', + + async run($) { + const { repoOwner, repo } = getRepoOwnerAndRepo($.step.parameters.repo); + + if (!repo) return { data: [] }; + + const firstPageRequest = $.http.get(`/repos/${repoOwner}/${repo}/labels`); + const response = await paginateAll($, firstPageRequest); + + response.data = response.data.map((repo) => { + return { + value: repo.name, + name: repo.name, + }; + }); + + return response; + }, +}; diff --git a/packages/backend/src/apps/github/dynamic-data/list-repos/index.js b/packages/backend/src/apps/github/dynamic-data/list-repos/index.js new file mode 100644 index 0000000..06c5d47 --- /dev/null +++ b/packages/backend/src/apps/github/dynamic-data/list-repos/index.js @@ -0,0 +1,20 @@ +import paginateAll from '../../common/paginate-all.js'; + +export default { + name: 'List repos', + key: 'listRepos', + + async run($) { + const firstPageRequest = $.http.get('/user/repos'); + const response = await paginateAll($, firstPageRequest); + + response.data = response.data.map((repo) => { + return { + value: repo.full_name, + name: repo.full_name, + }; + }); + + return response; + }, +}; diff --git a/packages/backend/src/apps/github/index.js b/packages/backend/src/apps/github/index.js new file mode 100644 index 0000000..194ed04 --- /dev/null +++ b/packages/backend/src/apps/github/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'GitHub', + key: 'github', + baseUrl: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + iconUrl: '{BASE_URL}/apps/github/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/github/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/github/triggers/index.js b/packages/backend/src/apps/github/triggers/index.js new file mode 100644 index 0000000..62b86bf --- /dev/null +++ b/packages/backend/src/apps/github/triggers/index.js @@ -0,0 +1,6 @@ +import newIssues from './new-issues/index.js'; +import newPullRequests from './new-pull-requests/index.js'; +import newStargazers from './new-stargazers/index.js'; +import newWatchers from './new-watchers/index.js'; + +export default [newIssues, newPullRequests, newStargazers, newWatchers]; diff --git a/packages/backend/src/apps/github/triggers/new-issues/index.js b/packages/backend/src/apps/github/triggers/new-issues/index.js new file mode 100644 index 0000000..699f889 --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-issues/index.js @@ -0,0 +1,86 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newIssues from './new-issues.js'; + +export default defineTrigger({ + name: 'New issues', + key: 'newIssues', + pollInterval: 15, + description: 'Triggers when a new issue is created', + arguments: [ + { + label: 'Repo', + key: 'repo', + type: 'dropdown', + required: false, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRepos', + }, + ], + }, + }, + { + label: 'Which types of issues should this trigger on?', + key: 'issueType', + type: 'dropdown', + description: 'Defaults to any issue you can see.', + required: true, + variables: false, + value: 'all', + options: [ + { + label: 'Any issue you can see', + value: 'all', + }, + { + label: 'Only issues assigned to you', + value: 'assigned', + }, + { + label: 'Only issues created by you', + value: 'created', + }, + { + label: `Only issues you're mentioned in`, + value: 'mentioned', + }, + { + label: `Only issues you're subscribed to`, + value: 'subscribed', + }, + ], + }, + { + label: 'Label', + key: 'label', + type: 'dropdown', + description: 'Only trigger on issues when this label is added.', + required: false, + variables: false, + dependsOn: ['parameters.repo'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLabels', + }, + { + name: 'parameters.repo', + value: '{parameters.repo}', + }, + ], + }, + }, + ], + + async run($) { + await newIssues($); + }, +}); diff --git a/packages/backend/src/apps/github/triggers/new-issues/new-issues.js b/packages/backend/src/apps/github/triggers/new-issues/new-issues.js new file mode 100644 index 0000000..ee5c17d --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-issues/new-issues.js @@ -0,0 +1,47 @@ +import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo.js'; +import parseLinkHeader from '../../../../helpers/parse-header-link.js'; + +function getPathname($) { + const { repoOwner, repo } = getRepoOwnerAndRepo($.step.parameters.repo); + + if (repoOwner && repo) { + return `/repos/${repoOwner}/${repo}/issues`; + } + + return '/issues'; +} + +const newIssues = async ($) => { + const pathname = getPathname($); + const params = { + labels: $.step.parameters.label, + filter: 'all', + state: 'all', + sort: 'created', + direction: 'desc', + per_page: 100, + }; + + let links; + do { + const response = await $.http.get(pathname, { params }); + links = parseLinkHeader(response.headers.link); + + if (response.data.length) { + for (const issue of response.data) { + const issueId = issue.id; + + const dataItem = { + raw: issue, + meta: { + internalId: issueId.toString(), + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (links.next); +}; + +export default newIssues; diff --git a/packages/backend/src/apps/github/triggers/new-pull-requests/index.js b/packages/backend/src/apps/github/triggers/new-pull-requests/index.js new file mode 100644 index 0000000..7b7f4b0 --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-pull-requests/index.js @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newPullRequests from './new-pull-requests.js'; + +export default defineTrigger({ + name: 'New pull requests', + key: 'newPullRequests', + pollInterval: 15, + description: 'Triggers when a new pull request is created', + arguments: [ + { + label: 'Repo', + key: 'repo', + type: 'dropdown', + required: true, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRepos', + }, + ], + }, + }, + ], + + async run($) { + await newPullRequests($); + }, +}); diff --git a/packages/backend/src/apps/github/triggers/new-pull-requests/new-pull-requests.js b/packages/backend/src/apps/github/triggers/new-pull-requests/new-pull-requests.js new file mode 100644 index 0000000..bbd6d8e --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-pull-requests/new-pull-requests.js @@ -0,0 +1,41 @@ +import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo.js'; +import parseLinkHeader from '../../../../helpers/parse-header-link.js'; + +const newPullRequests = async ($) => { + const repoParameter = $.step.parameters.repo; + + if (!repoParameter) throw new Error('A repo must be set!'); + + const { repoOwner, repo } = getRepoOwnerAndRepo(repoParameter); + + const pathname = `/repos/${repoOwner}/${repo}/pulls`; + const params = { + state: 'all', + sort: 'created', + direction: 'desc', + per_page: 100, + }; + + let links; + do { + const response = await $.http.get(pathname, { params }); + links = parseLinkHeader(response.headers.link); + + if (response.data.length) { + for (const pullRequest of response.data) { + const pullRequestId = pullRequest.id; + + const dataItem = { + raw: pullRequest, + meta: { + internalId: pullRequestId.toString(), + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (links.next); +}; + +export default newPullRequests; diff --git a/packages/backend/src/apps/github/triggers/new-stargazers/index.js b/packages/backend/src/apps/github/triggers/new-stargazers/index.js new file mode 100644 index 0000000..0953e55 --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-stargazers/index.js @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newStargazers from './new-stargazers.js'; + +export default defineTrigger({ + name: 'New stargazers', + key: 'newStargazers', + pollInterval: 15, + description: 'Triggers when a user stars a repository', + arguments: [ + { + label: 'Repo', + key: 'repo', + type: 'dropdown', + required: true, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRepos', + }, + ], + }, + }, + ], + + async run($) { + await newStargazers($); + }, +}); diff --git a/packages/backend/src/apps/github/triggers/new-stargazers/new-stargazers.js b/packages/backend/src/apps/github/triggers/new-stargazers/new-stargazers.js new file mode 100644 index 0000000..f83e0dd --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-stargazers/new-stargazers.js @@ -0,0 +1,51 @@ +import { DateTime } from 'luxon'; + +import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo.js'; +import parseLinkHeader from '../../../../helpers/parse-header-link.js'; + +const newStargazers = async ($) => { + const { repoOwner, repo } = getRepoOwnerAndRepo($.step.parameters.repo); + const firstPagePathname = `/repos/${repoOwner}/${repo}/stargazers`; + const requestConfig = { + params: { + per_page: 100, + }, + headers: { + // needed to get `starred_at` time + Accept: 'application/vnd.github.star+json', + }, + }; + + const firstPageResponse = await $.http.get(firstPagePathname, requestConfig); + const firstPageLinks = parseLinkHeader(firstPageResponse.headers.link); + + // in case there is only single page to fetch + let pathname = firstPageLinks.last?.uri || firstPagePathname; + + do { + const response = await $.http.get(pathname, requestConfig); + const links = parseLinkHeader(response.headers.link); + pathname = links.prev?.uri; + + if (response.data.length) { + // to iterate reverse-chronologically + response.data.reverse(); + + for (const starEntry of response.data) { + const { starred_at, user } = starEntry; + const timestamp = DateTime.fromISO(starred_at).toMillis(); + + const dataItem = { + raw: user, + meta: { + internalId: timestamp.toString(), + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (pathname); +}; + +export default newStargazers; diff --git a/packages/backend/src/apps/github/triggers/new-watchers/index.js b/packages/backend/src/apps/github/triggers/new-watchers/index.js new file mode 100644 index 0000000..62602e9 --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-watchers/index.js @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newWatchers from './new-watchers.js'; + +export default defineTrigger({ + name: 'New watchers', + key: 'newWatchers', + pollInterval: 15, + description: 'Triggers when a user watches a repository', + arguments: [ + { + label: 'Repo', + key: 'repo', + type: 'dropdown', + required: true, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRepos', + }, + ], + }, + }, + ], + + async run($) { + await newWatchers($); + }, +}); diff --git a/packages/backend/src/apps/github/triggers/new-watchers/new-watchers.js b/packages/backend/src/apps/github/triggers/new-watchers/new-watchers.js new file mode 100644 index 0000000..3ccfba4 --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-watchers/new-watchers.js @@ -0,0 +1,49 @@ +import getRepoOwnerAndRepo from '../../common/get-repo-owner-and-repo.js'; +import parseLinkHeader from '../../../../helpers/parse-header-link.js'; + +const newWatchers = async ($) => { + const repoParameter = $.step.parameters.repo; + + if (!repoParameter) throw new Error('A repo must be set!'); + + const { repoOwner, repo } = getRepoOwnerAndRepo(repoParameter); + + const firstPagePathname = `/repos/${repoOwner}/${repo}/subscribers`; + const requestConfig = { + params: { + per_page: 100, + }, + }; + + const firstPageResponse = await $.http.get(firstPagePathname, requestConfig); + const firstPageLinks = parseLinkHeader(firstPageResponse.headers.link); + + // in case there is only single page to fetch + let pathname = firstPageLinks.last?.uri || firstPagePathname; + + do { + const response = await $.http.get(pathname, requestConfig); + const links = parseLinkHeader(response.headers.link); + pathname = links.prev?.uri; + + if (response.data.length) { + // to iterate reverse-chronologically + response.data.reverse(); + + for (const watcher of response.data) { + const watcherId = watcher.id.toString(); + + const dataItem = { + raw: watcher, + meta: { + internalId: watcherId, + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (pathname); +}; + +export default newWatchers; diff --git a/packages/backend/src/apps/gitlab/assets/favicon.svg b/packages/backend/src/apps/gitlab/assets/favicon.svg new file mode 100644 index 0000000..b110869 --- /dev/null +++ b/packages/backend/src/apps/gitlab/assets/favicon.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/backend/src/apps/gitlab/auth/generate-auth-url.js b/packages/backend/src/apps/gitlab/auth/generate-auth-url.js new file mode 100644 index 0000000..f3a7fb7 --- /dev/null +++ b/packages/backend/src/apps/gitlab/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URL, URLSearchParams } from 'url'; +import getBaseUrl from '../common/get-base-url.js'; + +export default async function generateAuthUrl($) { + // ref: https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow + + const scopes = ['api', 'read_user']; + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: $.auth.data.oAuthRedirectUrl, + scope: scopes.join(' '), + response_type: 'code', + state: Date.now().toString(), + }); + + const baseUrl = getBaseUrl($); + const path = `/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url: new URL(path, baseUrl).toString(), + }); +} diff --git a/packages/backend/src/apps/gitlab/auth/index.js b/packages/backend/src/apps/gitlab/auth/index.js new file mode 100644 index 0000000..f975699 --- /dev/null +++ b/packages/backend/src/apps/gitlab/auth/index.js @@ -0,0 +1,63 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/gitlab/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Gitlab OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/gitlab#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'instanceUrl', + label: 'Gitlab instance URL', + type: 'string', + required: false, + readOnly: false, + value: 'https://gitlab.com', + placeholder: 'https://gitlab.com', + description: 'Your Gitlab instance URL. Default is https://gitlab.com.', + docUrl: 'https://automatisch.io/docs/gitlab#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/gitlab#client-id', + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/gitlab#client-secret', + clickToCopy: false, + }, + ], + + generateAuthUrl, + refreshToken, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/gitlab/auth/is-still-verified.js b/packages/backend/src/apps/gitlab/auth/is-still-verified.js new file mode 100644 index 0000000..1b9d46d --- /dev/null +++ b/packages/backend/src/apps/gitlab/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/gitlab/auth/refresh-token.js b/packages/backend/src/apps/gitlab/auth/refresh-token.js new file mode 100644 index 0000000..09b74b4 --- /dev/null +++ b/packages/backend/src/apps/gitlab/auth/refresh-token.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; + +const refreshToken = async ($) => { + // ref: https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post('/oauth/token', params.toString()); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + tokenType: data.token_type, + refreshToken: data.refresh_token, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/gitlab/auth/verify-credentials.js b/packages/backend/src/apps/gitlab/auth/verify-credentials.js new file mode 100644 index 0000000..02e794f --- /dev/null +++ b/packages/backend/src/apps/gitlab/auth/verify-credentials.js @@ -0,0 +1,43 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + // ref: https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow + + const response = await $.http.post( + '/oauth/token', + { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: $.auth.data.oAuthRedirectUrl, + }, + { + headers: { + Accept: 'application/json', + }, + } + ); + + const data = response.data; + + $.auth.data.accessToken = data.access_token; + + const currentUser = await getCurrentUser($); + const screenName = [currentUser.username, $.auth.data.instanceUrl] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + accessToken: data.access_token, + refreshToken: data.refresh_token, + scope: data.scope, + tokenType: data.token_type, + userId: currentUser.id, + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/gitlab/common/add-auth-header.js b/packages/backend/src/apps/gitlab/common/add-auth-header.js new file mode 100644 index 0000000..150b2b4 --- /dev/null +++ b/packages/backend/src/apps/gitlab/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers = requestConfig.headers || {}; + requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + } + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/gitlab/common/get-base-url.js b/packages/backend/src/apps/gitlab/common/get-base-url.js new file mode 100644 index 0000000..44e82c4 --- /dev/null +++ b/packages/backend/src/apps/gitlab/common/get-base-url.js @@ -0,0 +1,13 @@ +const getBaseUrl = ($) => { + if ($.auth.data.instanceUrl) { + return $.auth.data.instanceUrl; + } + + if ($.app.apiBaseUrl) { + return $.app.apiBaseUrl; + } + + return $.app.baseUrl; +}; + +export default getBaseUrl; diff --git a/packages/backend/src/apps/gitlab/common/get-current-user.js b/packages/backend/src/apps/gitlab/common/get-current-user.js new file mode 100644 index 0000000..f464d6a --- /dev/null +++ b/packages/backend/src/apps/gitlab/common/get-current-user.js @@ -0,0 +1,9 @@ +const getCurrentUser = async ($) => { + // ref: https://docs.gitlab.com/ee/api/users.html#list-current-user + + const response = await $.http.get('/api/v4/user'); + const currentUser = response.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/gitlab/common/paginate-all.js b/packages/backend/src/apps/gitlab/common/paginate-all.js new file mode 100644 index 0000000..060aa2f --- /dev/null +++ b/packages/backend/src/apps/gitlab/common/paginate-all.js @@ -0,0 +1,23 @@ +import parseLinkHeader from '../../../helpers/parse-header-link.js'; + +export default async function paginateAll($, request) { + const response = await request; + + const aggregatedResponse = { + data: [...response.data], + }; + + let links = parseLinkHeader(response.headers.link); + + while (links.next) { + const nextPageResponse = await $.http.request({ + ...response.config, + url: links.next.uri, + }); + + aggregatedResponse.data.push(...nextPageResponse.data); + links = parseLinkHeader(nextPageResponse.headers.link); + } + + return aggregatedResponse; +} diff --git a/packages/backend/src/apps/gitlab/common/set-base-url.js b/packages/backend/src/apps/gitlab/common/set-base-url.js new file mode 100644 index 0000000..135149b --- /dev/null +++ b/packages/backend/src/apps/gitlab/common/set-base-url.js @@ -0,0 +1,11 @@ +const setBaseUrl = ($, requestConfig) => { + if ($.auth.data.instanceUrl) { + requestConfig.baseURL = $.auth.data.instanceUrl; + } else if ($.app.apiBaseUrl) { + requestConfig.baseURL = $.app.apiBaseUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/gitlab/dynamic-data/index.js b/packages/backend/src/apps/gitlab/dynamic-data/index.js new file mode 100644 index 0000000..ed07bb0 --- /dev/null +++ b/packages/backend/src/apps/gitlab/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listProjects from './list-projects/index.js'; + +export default [listProjects]; diff --git a/packages/backend/src/apps/gitlab/dynamic-data/list-projects/index.js b/packages/backend/src/apps/gitlab/dynamic-data/list-projects/index.js new file mode 100644 index 0000000..522f7ad --- /dev/null +++ b/packages/backend/src/apps/gitlab/dynamic-data/list-projects/index.js @@ -0,0 +1,32 @@ +import paginateAll from '../../common/paginate-all.js'; + +export default { + name: 'List projects', + key: 'listProjects', + + async run($) { + // ref: + // - https://docs.gitlab.com/ee/api/projects.html#list-all-projects + // - https://docs.gitlab.com/ee/api/rest/index.html#keyset-based-pagination + const firstPageRequest = $.http.get('/api/v4/projects', { + params: { + simple: true, + pagination: 'keyset', + membership: true, + order_by: 'id', + sort: 'asc', + }, + }); + + const response = await paginateAll($, firstPageRequest); + + response.data = response.data.map((repo) => { + return { + value: repo.id, + name: repo.name, + }; + }); + + return response; + }, +}; diff --git a/packages/backend/src/apps/gitlab/index.js b/packages/backend/src/apps/gitlab/index.js new file mode 100644 index 0000000..098f7d5 --- /dev/null +++ b/packages/backend/src/apps/gitlab/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'GitLab', + key: 'gitlab', + baseUrl: 'https://gitlab.com', + apiBaseUrl: 'https://gitlab.com', + iconUrl: '{BASE_URL}/apps/gitlab/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/gitlab/connection', + primaryColor: '#FC6D26', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/gitlab/triggers/confidential-issue-event/index.js b/packages/backend/src/apps/gitlab/triggers/confidential-issue-event/index.js new file mode 100644 index 0000000..ec94b32 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/confidential-issue-event/index.js @@ -0,0 +1,28 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +// confidential_issues_events has the same event data as issues_events +import data from './issue_event.js'; + +export const triggerDescriptor = { + name: 'Confidential issue event', + description: + 'Confidential issue event (triggered when a new confidential issue is created or an existing issue is updated, closed, or reopened)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#issue-events', + key: GITLAB_EVENT_TYPE.confidential_issues_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.confidential_issues_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/confidential-issue-event/issue_event.js b/packages/backend/src/apps/gitlab/triggers/confidential-issue-event/issue_event.js new file mode 100644 index 0000000..75a243b --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/confidential-issue-event/issue_event.js @@ -0,0 +1,159 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#issue-events + +export default { + object_kind: 'issue', + event_type: 'issue', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + email: 'admin@example.com', + }, + project: { + id: 1, + name: 'Gitlab Test', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/gitlabhq/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + git_http_url: 'http://example.com/gitlabhq/gitlab-test.git', + namespace: 'GitlabHQ', + visibility_level: 20, + path_with_namespace: 'gitlabhq/gitlab-test', + default_branch: 'master', + ci_config_path: null, + homepage: 'http://example.com/gitlabhq/gitlab-test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + http_url: 'http://example.com/gitlabhq/gitlab-test.git', + }, + object_attributes: { + id: 301, + title: 'New API: create/update/delete file', + assignee_ids: [51], + assignee_id: 51, + author_id: 51, + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + updated_by_id: 1, + last_edited_at: null, + last_edited_by_id: null, + relative_position: 0, + description: 'Create new API for manipulations with repository', + milestone_id: null, + state_id: 1, + confidential: false, + discussion_locked: true, + due_date: null, + moved_to_id: null, + duplicated_to_id: null, + time_estimate: 0, + total_time_spent: 0, + time_change: 0, + human_total_time_spent: null, + human_time_estimate: null, + human_time_change: null, + weight: null, + iid: 23, + url: 'http://example.com/diaspora/issues/23', + state: 'opened', + action: 'open', + severity: 'high', + escalation_status: 'triggered', + escalation_policy: { + id: 18, + name: 'Engineering On-call', + }, + labels: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + }, + repository: { + name: 'Gitlab Test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + description: 'Aut reprehenderit ut est.', + homepage: 'http://example.com/gitlabhq/gitlab-test', + }, + assignees: [ + { + name: 'User1', + username: 'user1', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + }, + ], + assignee: { + name: 'User1', + username: 'user1', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + }, + labels: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + changes: { + updated_by_id: { + previous: null, + current: 1, + }, + updated_at: { + previous: '2017-09-15 16:50:55 UTC', + current: '2017-09-15 16:52:00 UTC', + }, + labels: { + previous: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + current: [ + { + id: 205, + title: 'Platform', + color: '#123123', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'Platform related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + }, + }, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/confidential-note-event/index.js b/packages/backend/src/apps/gitlab/triggers/confidential-note-event/index.js new file mode 100644 index 0000000..4e4fbd9 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/confidential-note-event/index.js @@ -0,0 +1,28 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +// confidential_note_events has the same event data as note_events +import data from './note_event.js'; + +export const triggerDescriptor = { + name: 'Confidential comment event', + description: + 'Confidential comment event (triggered when a new confidential comment is made on commits, merge requests, issues, and code snippets)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#comment-events', + key: GITLAB_EVENT_TYPE.confidential_note_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.confidential_note_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/confidential-note-event/note_event.js b/packages/backend/src/apps/gitlab/triggers/confidential-note-event/note_event.js new file mode 100644 index 0000000..593188f --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/confidential-note-event/note_event.js @@ -0,0 +1,74 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#comment-events + +export default { + object_kind: 'note', + event_type: 'note', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + email: 'admin@example.com', + }, + project_id: 5, + project: { + id: 5, + name: 'Gitlab Test', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/gitlabhq/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + git_http_url: 'http://example.com/gitlabhq/gitlab-test.git', + namespace: 'GitlabHQ', + visibility_level: 20, + path_with_namespace: 'gitlabhq/gitlab-test', + default_branch: 'master', + homepage: 'http://example.com/gitlabhq/gitlab-test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + http_url: 'http://example.com/gitlabhq/gitlab-test.git', + }, + repository: { + name: 'Gitlab Test', + url: 'http://example.com/gitlab-org/gitlab-test.git', + description: 'Aut reprehenderit ut est.', + homepage: 'http://example.com/gitlab-org/gitlab-test', + }, + object_attributes: { + id: 1243, + note: 'This is a commit comment. How does this work?', + noteable_type: 'Commit', + author_id: 1, + created_at: '2015-05-17 18:08:09 UTC', + updated_at: '2015-05-17 18:08:09 UTC', + project_id: 5, + attachment: null, + line_code: 'bec9703f7a456cd2b4ab5fb3220ae016e3e394e3_0_1', + commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + noteable_id: null, + system: false, + st_diff: { + diff: '--- /dev/null\n+++ b/six\n@@ -0,0 +1 @@\n+Subproject commit 409f37c4f05865e4fb208c771485f211a22c4c2d\n', + new_path: 'six', + old_path: 'six', + a_mode: '0', + b_mode: '160000', + new_file: true, + renamed_file: false, + deleted_file: false, + }, + url: 'http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660#note_1243', + }, + commit: { + id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + message: + 'Add submodule\n\nSigned-off-by: Example User \u003cuser@example.com.com\u003e\n', + timestamp: '2014-02-27T10:06:20+02:00', + url: 'http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + author: { + name: 'Example User', + email: 'user@example.com', + }, + }, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/deployment-event/deployment_event.js b/packages/backend/src/apps/gitlab/triggers/deployment-event/deployment_event.js new file mode 100644 index 0000000..0c2d285 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/deployment-event/deployment_event.js @@ -0,0 +1,45 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#deployment-events + +export default { + object_kind: 'deployment', + status: 'success', + status_changed_at: '2021-04-28 21:50:00 +0200', + deployment_id: 15, + deployable_id: 796, + deployable_url: + 'http://10.126.0.2:3000/root/test-deployment-webhooks/-/jobs/796', + environment: 'staging', + environment_slug: 'staging', + environment_external_url: 'https://staging.example.com', + project: { + id: 30, + name: 'test-deployment-webhooks', + description: '', + web_url: 'http://10.126.0.2:3000/root/test-deployment-webhooks', + avatar_url: null, + git_ssh_url: 'ssh://vlad@10.126.0.2:2222/root/test-deployment-webhooks.git', + git_http_url: 'http://10.126.0.2:3000/root/test-deployment-webhooks.git', + namespace: 'Administrator', + visibility_level: 0, + path_with_namespace: 'root/test-deployment-webhooks', + default_branch: 'master', + ci_config_path: '', + homepage: 'http://10.126.0.2:3000/root/test-deployment-webhooks', + url: 'ssh://vlad@10.126.0.2:2222/root/test-deployment-webhooks.git', + ssh_url: 'ssh://vlad@10.126.0.2:2222/root/test-deployment-webhooks.git', + http_url: 'http://10.126.0.2:3000/root/test-deployment-webhooks.git', + }, + short_sha: '279484c0', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + email: 'admin@example.com', + }, + user_url: 'http://10.126.0.2:3000/root', + commit_url: + 'http://10.126.0.2:3000/root/test-deployment-webhooks/-/commit/279484c09fbe69ededfced8c1bb6e6d24616b468', + commit_title: 'Add new file', +}; diff --git a/packages/backend/src/apps/gitlab/triggers/deployment-event/index.js b/packages/backend/src/apps/gitlab/triggers/deployment-event/index.js new file mode 100644 index 0000000..ecf122c --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/deployment-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './deployment_event.js'; + +export const triggerDescriptor = { + name: 'Deployment event', + description: + 'Deployment event (triggered when a deployment starts, succeeds, fails or is canceled)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#deployment-events', + key: GITLAB_EVENT_TYPE.deployment_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.deployment_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/feature-flag-event/feature_flag_event.js b/packages/backend/src/apps/gitlab/triggers/feature-flag-event/feature_flag_event.js new file mode 100644 index 0000000..bff8908 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/feature-flag-event/feature_flag_event.js @@ -0,0 +1,38 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#feature-flag-events + +export default { + object_kind: 'feature_flag', + project: { + id: 1, + name: 'Gitlab Test', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/gitlabhq/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + git_http_url: 'http://example.com/gitlabhq/gitlab-test.git', + namespace: 'GitlabHQ', + visibility_level: 20, + path_with_namespace: 'gitlabhq/gitlab-test', + default_branch: 'master', + ci_config_path: null, + homepage: 'http://example.com/gitlabhq/gitlab-test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + http_url: 'http://example.com/gitlabhq/gitlab-test.git', + }, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + email: 'admin@example.com', + }, + user_url: 'http://example.com/root', + object_attributes: { + id: 6, + name: 'test-feature-flag', + description: 'test-feature-flag-description', + active: true, + }, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/feature-flag-event/index.js b/packages/backend/src/apps/gitlab/triggers/feature-flag-event/index.js new file mode 100644 index 0000000..5c2ea03 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/feature-flag-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './feature_flag_event.js'; + +export const triggerDescriptor = { + name: 'Feature flag event', + description: + 'Feature flag event (triggered when a feature flag is turned on or off)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#feature-flag-events', + key: GITLAB_EVENT_TYPE.feature_flag_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.feature_flag_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/index.js b/packages/backend/src/apps/gitlab/triggers/index.js new file mode 100644 index 0000000..18ffd7d --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/index.js @@ -0,0 +1,29 @@ +import confidentialIssueEvent from './confidential-issue-event/index.js'; +import confidentialNoteEvent from './confidential-note-event/index.js'; +import deploymentEvent from './deployment-event/index.js'; +import featureFlagEvent from './feature-flag-event/index.js'; +import issueEvent from './issue-event/index.js'; +import jobEvent from './job-event/index.js'; +import mergeRequestEvent from './merge-request-event/index.js'; +import noteEvent from './note-event/index.js'; +import pipelineEvent from './pipeline-event/index.js'; +import pushEvent from './push-event/index.js'; +import releaseEvent from './release-event/index.js'; +import tagPushEvent from './tag-push-event/index.js'; +import wikiPageEvent from './wiki-page-event/index.js'; + +export default [ + confidentialIssueEvent, + confidentialNoteEvent, + deploymentEvent, + featureFlagEvent, + issueEvent, + jobEvent, + mergeRequestEvent, + noteEvent, + pipelineEvent, + pushEvent, + releaseEvent, + tagPushEvent, + wikiPageEvent, +]; diff --git a/packages/backend/src/apps/gitlab/triggers/issue-event/index.js b/packages/backend/src/apps/gitlab/triggers/issue-event/index.js new file mode 100644 index 0000000..a273df9 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/issue-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './issue_event.js'; + +export const triggerDescriptor = { + name: 'Issue event', + description: + 'Issue event (triggered when a new issue is created or an existing issue is updated, closed, or reopened)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#issue-events', + key: GITLAB_EVENT_TYPE.issues_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.issues_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/issue-event/issue_event.js b/packages/backend/src/apps/gitlab/triggers/issue-event/issue_event.js new file mode 100644 index 0000000..75a243b --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/issue-event/issue_event.js @@ -0,0 +1,159 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#issue-events + +export default { + object_kind: 'issue', + event_type: 'issue', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + email: 'admin@example.com', + }, + project: { + id: 1, + name: 'Gitlab Test', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/gitlabhq/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + git_http_url: 'http://example.com/gitlabhq/gitlab-test.git', + namespace: 'GitlabHQ', + visibility_level: 20, + path_with_namespace: 'gitlabhq/gitlab-test', + default_branch: 'master', + ci_config_path: null, + homepage: 'http://example.com/gitlabhq/gitlab-test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + http_url: 'http://example.com/gitlabhq/gitlab-test.git', + }, + object_attributes: { + id: 301, + title: 'New API: create/update/delete file', + assignee_ids: [51], + assignee_id: 51, + author_id: 51, + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + updated_by_id: 1, + last_edited_at: null, + last_edited_by_id: null, + relative_position: 0, + description: 'Create new API for manipulations with repository', + milestone_id: null, + state_id: 1, + confidential: false, + discussion_locked: true, + due_date: null, + moved_to_id: null, + duplicated_to_id: null, + time_estimate: 0, + total_time_spent: 0, + time_change: 0, + human_total_time_spent: null, + human_time_estimate: null, + human_time_change: null, + weight: null, + iid: 23, + url: 'http://example.com/diaspora/issues/23', + state: 'opened', + action: 'open', + severity: 'high', + escalation_status: 'triggered', + escalation_policy: { + id: 18, + name: 'Engineering On-call', + }, + labels: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + }, + repository: { + name: 'Gitlab Test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + description: 'Aut reprehenderit ut est.', + homepage: 'http://example.com/gitlabhq/gitlab-test', + }, + assignees: [ + { + name: 'User1', + username: 'user1', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + }, + ], + assignee: { + name: 'User1', + username: 'user1', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + }, + labels: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + changes: { + updated_by_id: { + previous: null, + current: 1, + }, + updated_at: { + previous: '2017-09-15 16:50:55 UTC', + current: '2017-09-15 16:52:00 UTC', + }, + labels: { + previous: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + current: [ + { + id: 205, + title: 'Platform', + color: '#123123', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'Platform related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + }, + }, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/job-event/index.js b/packages/backend/src/apps/gitlab/triggers/job-event/index.js new file mode 100644 index 0000000..f7c4f5a --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/job-event/index.js @@ -0,0 +1,26 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './job_event.js'; + +export const triggerDescriptor = { + name: 'Job event', + description: 'Job event (triggered when the status of a job changes)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#job-events', + key: GITLAB_EVENT_TYPE.job_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.job_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/job-event/job_event.js b/packages/backend/src/apps/gitlab/triggers/job-event/job_event.js new file mode 100644 index 0000000..bc23866 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/job-event/job_event.js @@ -0,0 +1,60 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#job-events + +export default { + object_kind: 'build', + ref: 'gitlab-script-trigger', + tag: false, + before_sha: '2293ada6b400935a1378653304eaf6221e0fdb8f', + sha: '2293ada6b400935a1378653304eaf6221e0fdb8f', + build_id: 1977, + build_name: 'test', + build_stage: 'test', + build_status: 'created', + build_created_at: '2021-02-23T02:41:37.886Z', + build_started_at: null, + build_finished_at: null, + build_duration: null, + build_queued_duration: 1095.588715, // duration in seconds + build_allow_failure: false, + build_failure_reason: 'script_failure', + retries_count: 2, // the second retry of this job + pipeline_id: 2366, + project_id: 380, + project_name: 'gitlab-org/gitlab-test', + user: { + id: 3, + name: 'User', + email: 'user@gitlab.com', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + }, + commit: { + id: 2366, + name: 'Build pipeline', + sha: '2293ada6b400935a1378653304eaf6221e0fdb8f', + message: 'test\n', + author_name: 'User', + author_email: 'user@gitlab.com', + status: 'created', + duration: null, + started_at: null, + finished_at: null, + }, + repository: { + name: 'gitlab_test', + description: 'Atque in sunt eos similique dolores voluptatem.', + homepage: 'http://192.168.64.1:3005/gitlab-org/gitlab-test', + git_ssh_url: 'git@192.168.64.1:gitlab-org/gitlab-test.git', + git_http_url: 'http://192.168.64.1:3005/gitlab-org/gitlab-test.git', + visibility_level: 20, + }, + runner: { + active: true, + runner_type: 'project_type', + is_shared: false, + id: 380987, + description: 'shared-runners-manager-6.gitlab.com', + tags: ['linux', 'docker'], + }, + environment: null, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/lib.js b/packages/backend/src/apps/gitlab/triggers/lib.js new file mode 100644 index 0000000..1203722 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/lib.js @@ -0,0 +1,94 @@ +import Crypto from 'crypto'; +import appConfig from '../../../config/app.js'; + +export const projectArgumentDescriptor = { + label: 'Project', + key: 'projectId', + type: 'dropdown', + required: true, + description: 'Pick a project to receive events from', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listProjects', + }, + ], + }, +}; + +export const getRunFn = async ($) => { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); +}; + +export const getTestRunFn = (eventData) => ($) => { + /* + Not fetching actual events from gitlab and using static event data from documentation + as there is no way to filter out events of one category using gitlab event types, + filtering is very limited and uses different grouping than what is applicable when creating a webhook. + + ref: + - https://docs.gitlab.com/ee/api/events.html#target-types + - https://docs.gitlab.com/ee/api/projects.html#add-project-hook + */ + + if (!eventData) { + return; + } + + const dataItem = { + raw: eventData, + meta: { + // there is no distinct id on gitlab event object thus creating it + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + + return Promise.resolve(); +}; + +export const getRegisterHookFn = (eventType) => async ($) => { + // ref: https://docs.gitlab.com/ee/api/projects.html#add-project-hook + + const subscriptionPayload = { + url: $.webhookUrl, + token: appConfig.webhookSecretKey, + enable_ssl_verification: true, + [eventType]: true, + }; + + if ( + ['wildcard', 'regex'].includes($.step.parameters.branch_filter_strategy) + ) { + subscriptionPayload.branch_filter_strategy = + $.step.parameters.branch_filter_strategy; + subscriptionPayload.push_events_branch_filter = + $.step.parameters.push_events_branch_filter; + } + + const { data } = await $.http.post( + `/api/v4/projects/${$.step.parameters.projectId}/hooks`, + subscriptionPayload + ); + + await $.flow.setRemoteWebhookId(data.id.toString()); +}; + +export const unregisterHook = async ($) => { + // ref: https://docs.gitlab.com/ee/api/projects.html#delete-project-hook + await $.http.delete( + `/api/v4/projects/${$.step.parameters.projectId}/hooks/${$.flow.remoteWebhookId}` + ); +}; diff --git a/packages/backend/src/apps/gitlab/triggers/merge-request-event/index.js b/packages/backend/src/apps/gitlab/triggers/merge-request-event/index.js new file mode 100644 index 0000000..c3ec583 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/merge-request-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './merge_request_event.js'; + +export const triggerDescriptor = { + name: 'Merge request event', + description: + 'Merge request event (triggered when merge request is created, updated, or closed)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#merge-request-events', + key: GITLAB_EVENT_TYPE.merge_requests_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.merge_requests_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/merge-request-event/merge_request_event.js b/packages/backend/src/apps/gitlab/triggers/merge-request-event/merge_request_event.js new file mode 100644 index 0000000..e0f3f48 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/merge-request-event/merge_request_event.js @@ -0,0 +1,208 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#merge-request-events + +export default { + object_kind: 'merge_request', + event_type: 'merge_request', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + email: 'admin@example.com', + }, + project: { + id: 1, + name: 'Gitlab Test', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/gitlabhq/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + git_http_url: 'http://example.com/gitlabhq/gitlab-test.git', + namespace: 'GitlabHQ', + visibility_level: 20, + path_with_namespace: 'gitlabhq/gitlab-test', + default_branch: 'master', + ci_config_path: '', + homepage: 'http://example.com/gitlabhq/gitlab-test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + http_url: 'http://example.com/gitlabhq/gitlab-test.git', + }, + repository: { + name: 'Gitlab Test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + description: 'Aut reprehenderit ut est.', + homepage: 'http://example.com/gitlabhq/gitlab-test', + }, + object_attributes: { + id: 99, + iid: 1, + target_branch: 'master', + source_branch: 'ms-viewport', + source_project_id: 14, + author_id: 51, + assignee_ids: [6], + assignee_id: 6, + reviewer_ids: [6], + title: 'MS-Viewport', + created_at: '2013-12-03T17:23:34Z', + updated_at: '2013-12-03T17:23:34Z', + last_edited_at: '2013-12-03T17:23:34Z', + last_edited_by_id: 1, + milestone_id: null, + state_id: 1, + state: 'opened', + blocking_discussions_resolved: true, + work_in_progress: false, + first_contribution: true, + merge_status: 'unchecked', + target_project_id: 14, + description: '', + total_time_spent: 1800, + time_change: 30, + human_total_time_spent: '30m', + human_time_change: '30s', + human_time_estimate: '30m', + url: 'http://example.com/diaspora/merge_requests/1', + source: { + name: 'Awesome Project', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/awesome_space/awesome_project', + avatar_url: null, + git_ssh_url: 'git@example.com:awesome_space/awesome_project.git', + git_http_url: 'http://example.com/awesome_space/awesome_project.git', + namespace: 'Awesome Space', + visibility_level: 20, + path_with_namespace: 'awesome_space/awesome_project', + default_branch: 'master', + homepage: 'http://example.com/awesome_space/awesome_project', + url: 'http://example.com/awesome_space/awesome_project.git', + ssh_url: 'git@example.com:awesome_space/awesome_project.git', + http_url: 'http://example.com/awesome_space/awesome_project.git', + }, + target: { + name: 'Awesome Project', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/awesome_space/awesome_project', + avatar_url: null, + git_ssh_url: 'git@example.com:awesome_space/awesome_project.git', + git_http_url: 'http://example.com/awesome_space/awesome_project.git', + namespace: 'Awesome Space', + visibility_level: 20, + path_with_namespace: 'awesome_space/awesome_project', + default_branch: 'master', + homepage: 'http://example.com/awesome_space/awesome_project', + url: 'http://example.com/awesome_space/awesome_project.git', + ssh_url: 'git@example.com:awesome_space/awesome_project.git', + http_url: 'http://example.com/awesome_space/awesome_project.git', + }, + last_commit: { + id: 'da1560886d4f094c3e6c9ef40349f7d38b5d27d7', + message: 'fixed readme', + title: 'Update file README.md', + timestamp: '2012-01-03T23:36:29+02:00', + url: 'http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7', + author: { + name: 'GitLab dev user', + email: 'gitlabdev@dv6700.(none)', + }, + }, + labels: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + action: 'open', + detailed_merge_status: 'mergeable', + }, + labels: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + changes: { + updated_by_id: { + previous: null, + current: 1, + }, + updated_at: { + previous: '2017-09-15 16:50:55 UTC', + current: '2017-09-15 16:52:00 UTC', + }, + labels: { + previous: [ + { + id: 206, + title: 'API', + color: '#ffffff', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'API related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + current: [ + { + id: 205, + title: 'Platform', + color: '#123123', + project_id: 14, + created_at: '2013-12-03T17:15:43Z', + updated_at: '2013-12-03T17:15:43Z', + template: false, + description: 'Platform related issues', + type: 'ProjectLabel', + group_id: 41, + }, + ], + }, + last_edited_at: { + previous: null, + current: '2023-03-15 00:00:10 UTC', + }, + last_edited_by_id: { + previous: null, + current: 3278533, + }, + }, + assignees: [ + { + id: 6, + name: 'User1', + username: 'user1', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + }, + ], + reviewers: [ + { + id: 6, + name: 'User1', + username: 'user1', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + }, + ], +}; diff --git a/packages/backend/src/apps/gitlab/triggers/note-event/index.js b/packages/backend/src/apps/gitlab/triggers/note-event/index.js new file mode 100644 index 0000000..a74490a --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/note-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './note_event.js'; + +export const triggerDescriptor = { + name: 'Comment event', + description: + 'Comment event (triggered when a new comment is made on commits, merge requests, issues, and code snippets)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#comment-events', + key: GITLAB_EVENT_TYPE.note_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.note_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/note-event/note_event.js b/packages/backend/src/apps/gitlab/triggers/note-event/note_event.js new file mode 100644 index 0000000..593188f --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/note-event/note_event.js @@ -0,0 +1,74 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#comment-events + +export default { + object_kind: 'note', + event_type: 'note', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon', + email: 'admin@example.com', + }, + project_id: 5, + project: { + id: 5, + name: 'Gitlab Test', + description: 'Aut reprehenderit ut est.', + web_url: 'http://example.com/gitlabhq/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + git_http_url: 'http://example.com/gitlabhq/gitlab-test.git', + namespace: 'GitlabHQ', + visibility_level: 20, + path_with_namespace: 'gitlabhq/gitlab-test', + default_branch: 'master', + homepage: 'http://example.com/gitlabhq/gitlab-test', + url: 'http://example.com/gitlabhq/gitlab-test.git', + ssh_url: 'git@example.com:gitlabhq/gitlab-test.git', + http_url: 'http://example.com/gitlabhq/gitlab-test.git', + }, + repository: { + name: 'Gitlab Test', + url: 'http://example.com/gitlab-org/gitlab-test.git', + description: 'Aut reprehenderit ut est.', + homepage: 'http://example.com/gitlab-org/gitlab-test', + }, + object_attributes: { + id: 1243, + note: 'This is a commit comment. How does this work?', + noteable_type: 'Commit', + author_id: 1, + created_at: '2015-05-17 18:08:09 UTC', + updated_at: '2015-05-17 18:08:09 UTC', + project_id: 5, + attachment: null, + line_code: 'bec9703f7a456cd2b4ab5fb3220ae016e3e394e3_0_1', + commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + noteable_id: null, + system: false, + st_diff: { + diff: '--- /dev/null\n+++ b/six\n@@ -0,0 +1 @@\n+Subproject commit 409f37c4f05865e4fb208c771485f211a22c4c2d\n', + new_path: 'six', + old_path: 'six', + a_mode: '0', + b_mode: '160000', + new_file: true, + renamed_file: false, + deleted_file: false, + }, + url: 'http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660#note_1243', + }, + commit: { + id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + message: + 'Add submodule\n\nSigned-off-by: Example User \u003cuser@example.com.com\u003e\n', + timestamp: '2014-02-27T10:06:20+02:00', + url: 'http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + author: { + name: 'Example User', + email: 'user@example.com', + }, + }, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/pipeline-event/index.js b/packages/backend/src/apps/gitlab/triggers/pipeline-event/index.js new file mode 100644 index 0000000..216ddc5 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/pipeline-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './pipeline_event.js'; + +export const triggerDescriptor = { + name: 'Pipeline event', + description: + 'Pipeline event (triggered when the status of a pipeline changes)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#pipeline-events', + key: GITLAB_EVENT_TYPE.pipeline_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.pipeline_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/pipeline-event/pipeline_event.js b/packages/backend/src/apps/gitlab/triggers/pipeline-event/pipeline_event.js new file mode 100644 index 0000000..4a29b41 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/pipeline-event/pipeline_event.js @@ -0,0 +1,254 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#pipeline-events + +export default { + object_kind: 'pipeline', + object_attributes: { + id: 31, + iid: 3, + ref: 'master', + tag: false, + sha: 'bcbb5ec396a2c0f828686f14fac9b80b780504f2', + before_sha: 'bcbb5ec396a2c0f828686f14fac9b80b780504f2', + source: 'merge_request_event', + status: 'success', + stages: ['build', 'test', 'deploy'], + created_at: '2016-08-12 15:23:28 UTC', + finished_at: '2016-08-12 15:26:29 UTC', + duration: 63, + variables: [ + { + key: 'NESTOR_PROD_ENVIRONMENT', + value: 'us-west-1', + }, + ], + }, + merge_request: { + id: 1, + iid: 1, + title: 'Test', + source_branch: 'test', + source_project_id: 1, + target_branch: 'master', + target_project_id: 1, + state: 'opened', + merge_status: 'can_be_merged', + detailed_merge_status: 'mergeable', + url: 'http://192.168.64.1:3005/gitlab-org/gitlab-test/merge_requests/1', + }, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + email: 'user_email@gitlab.com', + }, + project: { + id: 1, + name: 'Gitlab Test', + description: 'Atque in sunt eos similique dolores voluptatem.', + web_url: 'http://192.168.64.1:3005/gitlab-org/gitlab-test', + avatar_url: null, + git_ssh_url: 'git@192.168.64.1:gitlab-org/gitlab-test.git', + git_http_url: 'http://192.168.64.1:3005/gitlab-org/gitlab-test.git', + namespace: 'Gitlab Org', + visibility_level: 20, + path_with_namespace: 'gitlab-org/gitlab-test', + default_branch: 'master', + }, + commit: { + id: 'bcbb5ec396a2c0f828686f14fac9b80b780504f2', + message: 'test\n', + timestamp: '2016-08-12T17:23:21+02:00', + url: 'http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2', + author: { + name: 'User', + email: 'user@gitlab.com', + }, + }, + source_pipeline: { + project: { + id: 41, + web_url: 'https://gitlab.example.com/gitlab-org/upstream-project', + path_with_namespace: 'gitlab-org/upstream-project', + }, + pipeline_id: 30, + job_id: 3401, + }, + builds: [ + { + id: 380, + stage: 'deploy', + name: 'production', + status: 'skipped', + created_at: '2016-08-12 15:23:28 UTC', + started_at: null, + finished_at: null, + duration: null, + queued_duration: null, + failure_reason: null, + when: 'manual', + manual: true, + allow_failure: false, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + email: 'admin@example.com', + }, + runner: null, + artifacts_file: { + filename: null, + size: null, + }, + environment: { + name: 'production', + action: 'start', + deployment_tier: 'production', + }, + }, + { + id: 377, + stage: 'test', + name: 'test-image', + status: 'success', + created_at: '2016-08-12 15:23:28 UTC', + started_at: '2016-08-12 15:26:12 UTC', + finished_at: '2016-08-12 15:26:29 UTC', + duration: 17.0, + queued_duration: 196.0, + failure_reason: null, + when: 'on_success', + manual: false, + allow_failure: false, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + email: 'admin@example.com', + }, + runner: { + id: 380987, + description: 'shared-runners-manager-6.gitlab.com', + active: true, + runner_type: 'instance_type', + is_shared: true, + tags: ['linux', 'docker', 'shared-runner'], + }, + artifacts_file: { + filename: null, + size: null, + }, + environment: null, + }, + { + id: 378, + stage: 'test', + name: 'test-build', + status: 'failed', + created_at: '2016-08-12 15:23:28 UTC', + started_at: '2016-08-12 15:26:12 UTC', + finished_at: '2016-08-12 15:26:29 UTC', + duration: 17.0, + queued_duration: 196.0, + failure_reason: 'script_failure', + when: 'on_success', + manual: false, + allow_failure: false, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + email: 'admin@example.com', + }, + runner: { + id: 380987, + description: 'shared-runners-manager-6.gitlab.com', + active: true, + runner_type: 'instance_type', + is_shared: true, + tags: ['linux', 'docker'], + }, + artifacts_file: { + filename: null, + size: null, + }, + environment: null, + }, + { + id: 376, + stage: 'build', + name: 'build-image', + status: 'success', + created_at: '2016-08-12 15:23:28 UTC', + started_at: '2016-08-12 15:24:56 UTC', + finished_at: '2016-08-12 15:25:26 UTC', + duration: 17.0, + queued_duration: 196.0, + failure_reason: null, + when: 'on_success', + manual: false, + allow_failure: false, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + email: 'admin@example.com', + }, + runner: { + id: 380987, + description: 'shared-runners-manager-6.gitlab.com', + active: true, + runner_type: 'instance_type', + is_shared: true, + tags: ['linux', 'docker'], + }, + artifacts_file: { + filename: null, + size: null, + }, + environment: null, + }, + { + id: 379, + stage: 'deploy', + name: 'staging', + status: 'created', + created_at: '2016-08-12 15:23:28 UTC', + started_at: null, + finished_at: null, + duration: null, + queued_duration: null, + failure_reason: null, + when: 'on_success', + manual: false, + allow_failure: false, + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon', + email: 'admin@example.com', + }, + runner: null, + artifacts_file: { + filename: null, + size: null, + }, + environment: { + name: 'staging', + action: 'start', + deployment_tier: 'staging', + }, + }, + ], +}; diff --git a/packages/backend/src/apps/gitlab/triggers/push-event/index.js b/packages/backend/src/apps/gitlab/triggers/push-event/index.js new file mode 100644 index 0000000..83d3339 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/push-event/index.js @@ -0,0 +1,63 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './push_event.js'; + +export const branchFilterStrategyArgumentDescriptor = { + label: 'What type of filter to use?', + key: 'branch_filter_strategy', + type: 'dropdown', + description: 'Defaults to including all branches', + required: true, + variables: false, + value: 'all_branches', + options: [ + { + label: 'All branches', + value: 'all_branches', + }, + { + label: 'Wildcard pattern (ex: *-stable)', + value: 'wildcard', + }, + { + label: 'Regular expression (ex: ^(feature|hotfix)/)', + value: 'regex', + }, + ], +}; + +export const pushEventsBranchFilterArgumentDescriptor = { + label: 'Filter value', + key: 'push_events_branch_filter', + description: 'Leave empty when using "all branches"', + type: 'string', + required: false, + variables: false, +}; + +export const triggerDescriptor = { + name: 'Push event', + description: 'Push event (triggered when you push to the repository)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#push-events', + key: GITLAB_EVENT_TYPE.push_events, + type: 'webhook', + arguments: [ + projectArgumentDescriptor, + branchFilterStrategyArgumentDescriptor, + pushEventsBranchFilterArgumentDescriptor, + ], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.push_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/push-event/push_event.js b/packages/backend/src/apps/gitlab/triggers/push-event/push_event.js new file mode 100644 index 0000000..2951c15 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/push-event/push_event.js @@ -0,0 +1,75 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#push-events + +export default { + object_kind: 'push', + event_name: 'push', + before: '95790bf891e76fee5e1747ab589903a6a1f80f22', + after: 'da1560886d4f094c3e6c9ef40349f7d38b5d27d7', + ref: 'refs/heads/master', + checkout_sha: 'da1560886d4f094c3e6c9ef40349f7d38b5d27d7', + user_id: 4, + user_name: 'John Smith', + user_username: 'jsmith', + user_email: 'john@example.com', + user_avatar: + 'https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80', + project_id: 15, + project: { + id: 15, + name: 'Diaspora', + description: '', + web_url: 'http://example.com/mike/diaspora', + avatar_url: null, + git_ssh_url: 'git@example.com:mike/diaspora.git', + git_http_url: 'http://example.com/mike/diaspora.git', + namespace: 'Mike', + visibility_level: 0, + path_with_namespace: 'mike/diaspora', + default_branch: 'master', + homepage: 'http://example.com/mike/diaspora', + url: 'git@example.com:mike/diaspora.git', + ssh_url: 'git@example.com:mike/diaspora.git', + http_url: 'http://example.com/mike/diaspora.git', + }, + repository: { + name: 'Diaspora', + url: 'git@example.com:mike/diaspora.git', + description: '', + homepage: 'http://example.com/mike/diaspora', + git_http_url: 'http://example.com/mike/diaspora.git', + git_ssh_url: 'git@example.com:mike/diaspora.git', + visibility_level: 0, + }, + commits: [ + { + id: 'b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327', + message: + 'Update Catalan translation to e38cb41.\n\nSee https://gitlab.com/gitlab-org/gitlab for more information', + title: 'Update Catalan translation to e38cb41.', + timestamp: '2011-12-12T14:27:31+02:00', + url: 'http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327', + author: { + name: 'Jordi Mallach', + email: 'jordi@softcatala.org', + }, + added: ['CHANGELOG'], + modified: ['app/controller/application.rb'], + removed: [], + }, + { + id: 'da1560886d4f094c3e6c9ef40349f7d38b5d27d7', + message: 'fixed readme', + title: 'fixed readme', + timestamp: '2012-01-03T23:36:29+02:00', + url: 'http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7', + author: { + name: 'GitLab dev user', + email: 'gitlabdev@dv6700.(none)', + }, + added: ['CHANGELOG'], + modified: ['app/controller/application.rb'], + removed: [], + }, + ], + total_commits_count: 4, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/release-event/index.js b/packages/backend/src/apps/gitlab/triggers/release-event/index.js new file mode 100644 index 0000000..87e7c85 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/release-event/index.js @@ -0,0 +1,26 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './release_event.js'; + +export const triggerDescriptor = { + name: 'Release event', + description: 'Release event (triggered when a release is created or updated)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#release-events', + key: GITLAB_EVENT_TYPE.releases_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.releases_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/release-event/release_event.js b/packages/backend/src/apps/gitlab/triggers/release-event/release_event.js new file mode 100644 index 0000000..90c758b --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/release-event/release_event.js @@ -0,0 +1,72 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#release-events + +export default { + object_kind: 'release', + id: 1, + created_at: '2020-11-02 12:55:12 UTC', + description: 'v1.1 has been released', + name: 'v1.1', + released_at: '2020-11-02 12:55:12 UTC', + tag: 'v1.1', + project: { + id: 2, + name: 'release-webhook-example', + description: '', + web_url: 'https://example.com/gitlab-org/release-webhook-example', + avatar_url: null, + git_ssh_url: 'ssh://git@example.com/gitlab-org/release-webhook-example.git', + git_http_url: 'https://example.com/gitlab-org/release-webhook-example.git', + namespace: 'Gitlab', + visibility_level: 0, + path_with_namespace: 'gitlab-org/release-webhook-example', + default_branch: 'master', + ci_config_path: null, + homepage: 'https://example.com/gitlab-org/release-webhook-example', + url: 'ssh://git@example.com/gitlab-org/release-webhook-example.git', + ssh_url: 'ssh://git@example.com/gitlab-org/release-webhook-example.git', + http_url: 'https://example.com/gitlab-org/release-webhook-example.git', + }, + url: 'https://example.com/gitlab-org/release-webhook-example/-/releases/v1.1', + action: 'create', + assets: { + count: 5, + links: [ + { + id: 1, + external: true, // deprecated in GitLab 15.9, will be removed in GitLab 16.0. + link_type: 'other', + name: 'Changelog', + url: 'https://example.net/changelog', + }, + ], + sources: [ + { + format: 'zip', + url: 'https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.zip', + }, + { + format: 'tar.gz', + url: 'https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar.gz', + }, + { + format: 'tar.bz2', + url: 'https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar.bz2', + }, + { + format: 'tar', + url: 'https://example.com/gitlab-org/release-webhook-example/-/archive/v1.1/release-webhook-example-v1.1.tar', + }, + ], + }, + commit: { + id: 'ee0a3fb31ac16e11b9dbb596ad16d4af654d08f8', + message: 'Release v1.1', + title: 'Release v1.1', + timestamp: '2020-10-31T14:58:32+11:00', + url: 'https://example.com/gitlab-org/release-webhook-example/-/commit/ee0a3fb31ac16e11b9dbb596ad16d4af654d08f8', + author: { + name: 'Example User', + email: 'user@example.com', + }, + }, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/tag-push-event/index.js b/packages/backend/src/apps/gitlab/triggers/tag-push-event/index.js new file mode 100644 index 0000000..a64dfb3 --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/tag-push-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './tag_push_event.js'; + +export const triggerDescriptor = { + name: 'Tag event', + description: + 'Tag event (triggered when you create or delete tags in the repository)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#tag-events', + key: GITLAB_EVENT_TYPE.tag_push_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.tag_push_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/tag-push-event/tag_push_event.js b/packages/backend/src/apps/gitlab/triggers/tag-push-event/tag_push_event.js new file mode 100644 index 0000000..8dd94cf --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/tag-push-event/tag_push_event.js @@ -0,0 +1,43 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#tag-events + +export default { + object_kind: 'tag_push', + event_name: 'tag_push', + before: '0000000000000000000000000000000000000000', + after: '82b3d5ae55f7080f1e6022629cdb57bfae7cccc7', + ref: 'refs/tags/v1.0.0', + checkout_sha: '82b3d5ae55f7080f1e6022629cdb57bfae7cccc7', + user_id: 1, + user_name: 'John Smith', + user_avatar: + 'https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80', + project_id: 1, + project: { + id: 1, + name: 'Example', + description: '', + web_url: 'http://example.com/jsmith/example', + avatar_url: null, + git_ssh_url: 'git@example.com:jsmith/example.git', + git_http_url: 'http://example.com/jsmith/example.git', + namespace: 'Jsmith', + visibility_level: 0, + path_with_namespace: 'jsmith/example', + default_branch: 'master', + homepage: 'http://example.com/jsmith/example', + url: 'git@example.com:jsmith/example.git', + ssh_url: 'git@example.com:jsmith/example.git', + http_url: 'http://example.com/jsmith/example.git', + }, + repository: { + name: 'Example', + url: 'ssh://git@example.com/jsmith/example.git', + description: '', + homepage: 'http://example.com/jsmith/example', + git_http_url: 'http://example.com/jsmith/example.git', + git_ssh_url: 'git@example.com:jsmith/example.git', + visibility_level: 0, + }, + commits: [], + total_commits_count: 0, +}; diff --git a/packages/backend/src/apps/gitlab/triggers/types.js b/packages/backend/src/apps/gitlab/triggers/types.js new file mode 100644 index 0000000..cadc42e --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/types.js @@ -0,0 +1,15 @@ +export const GITLAB_EVENT_TYPE = { + confidential_issues_events: 'confidential_issues_events', + confidential_note_events: 'confidential_note_events', + deployment_events: 'deployment_events', + feature_flag_events: 'feature_flag_events', + issues_events: 'issues_events', + job_events: 'job_events', + merge_requests_events: 'merge_requests_events', + note_events: 'note_events', + pipeline_events: 'pipeline_events', + push_events: 'push_events', + releases_events: 'releases_events', + tag_push_events: 'tag_push_events', + wiki_page_events: 'wiki_page_events', +}; diff --git a/packages/backend/src/apps/gitlab/triggers/wiki-page-event/index.js b/packages/backend/src/apps/gitlab/triggers/wiki-page-event/index.js new file mode 100644 index 0000000..e92f8ca --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/wiki-page-event/index.js @@ -0,0 +1,27 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import { GITLAB_EVENT_TYPE } from '../types.js'; +import { + getRegisterHookFn, + getRunFn, + getTestRunFn, + projectArgumentDescriptor, + unregisterHook, +} from '../lib.js'; + +import data from './wiki_page_event.js'; + +export const triggerDescriptor = { + name: 'Wiki page event', + description: + 'Wiki page event (triggered when a wiki page is created, updated, or deleted)', + // info: 'https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#wiki-page-events', + key: GITLAB_EVENT_TYPE.wiki_page_events, + type: 'webhook', + arguments: [projectArgumentDescriptor], + run: ($) => getRunFn($), + testRun: getTestRunFn(data), + registerHook: getRegisterHookFn(GITLAB_EVENT_TYPE.wiki_page_events), + unregisterHook, +}; + +export default defineTrigger(triggerDescriptor); diff --git a/packages/backend/src/apps/gitlab/triggers/wiki-page-event/wiki_page_event.js b/packages/backend/src/apps/gitlab/triggers/wiki-page-event/wiki_page_event.js new file mode 100644 index 0000000..3058eea --- /dev/null +++ b/packages/backend/src/apps/gitlab/triggers/wiki-page-event/wiki_page_event.js @@ -0,0 +1,48 @@ +// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#wiki-page-events + +export default { + object_kind: 'wiki_page', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + email: 'admin@example.com', + }, + project: { + id: 1, + name: 'awesome-project', + description: 'This is awesome', + web_url: 'http://example.com/root/awesome-project', + avatar_url: null, + git_ssh_url: 'git@example.com:root/awesome-project.git', + git_http_url: 'http://example.com/root/awesome-project.git', + namespace: 'root', + visibility_level: 0, + path_with_namespace: 'root/awesome-project', + default_branch: 'master', + homepage: 'http://example.com/root/awesome-project', + url: 'git@example.com:root/awesome-project.git', + ssh_url: 'git@example.com:root/awesome-project.git', + http_url: 'http://example.com/root/awesome-project.git', + }, + wiki: { + web_url: 'http://example.com/root/awesome-project/-/wikis/home', + git_ssh_url: 'git@example.com:root/awesome-project.wiki.git', + git_http_url: 'http://example.com/root/awesome-project.wiki.git', + path_with_namespace: 'root/awesome-project.wiki', + default_branch: 'master', + }, + object_attributes: { + title: 'Awesome', + content: 'awesome content goes here', + format: 'markdown', + message: 'adding an awesome page to the wiki', + slug: 'awesome', + url: 'http://example.com/root/awesome-project/-/wikis/awesome', + action: 'create', + diff_url: + 'http://example.com/root/awesome-project/-/wikis/home/diff?version_id=78ee4a6705abfbff4f4132c6646dbaae9c8fb6ec', + }, +}; diff --git a/packages/backend/src/apps/google-calendar/assets/favicon.svg b/packages/backend/src/apps/google-calendar/assets/favicon.svg new file mode 100644 index 0000000..14b505a --- /dev/null +++ b/packages/backend/src/apps/google-calendar/assets/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/packages/backend/src/apps/google-calendar/auth/generate-auth-url.js b/packages/backend/src/apps/google-calendar/auth/generate-auth-url.js new file mode 100644 index 0000000..c972ae1 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + prompt: 'select_account', + scope: authScope.join(' '), + response_type: 'code', + access_type: 'offline', + }); + + const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/google-calendar/auth/index.js b/packages/backend/src/apps/google-calendar/auth/index.js new file mode 100644 index 0000000..a481e43 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/google-calendar/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Google Cloud, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/google-calendar/auth/is-still-verified.js b/packages/backend/src/apps/google-calendar/auth/is-still-verified.js new file mode 100644 index 0000000..68f4d7d --- /dev/null +++ b/packages/backend/src/apps/google-calendar/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.resourceName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/google-calendar/auth/refresh-token.js b/packages/backend/src/apps/google-calendar/auth/refresh-token.js new file mode 100644 index 0000000..7c5b702 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/auth/refresh-token.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://oauth2.googleapis.com/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/google-calendar/auth/verify-credentials.js b/packages/backend/src/apps/google-calendar/auth/verify-credentials.js new file mode 100644 index 0000000..a636b72 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/auth/verify-credentials.js @@ -0,0 +1,42 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + const { displayName } = currentUser.names.find( + (name) => name.metadata.primary + ); + const { value: email } = currentUser.emailAddresses.find( + (emailAddress) => emailAddress.metadata.primary + ); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + resourceName: currentUser.resourceName, + screenName: `${displayName} - ${email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/google-calendar/common/add-auth-header.js b/packages/backend/src/apps/google-calendar/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/google-calendar/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/google-calendar/common/auth-scope.js b/packages/backend/src/apps/google-calendar/common/auth-scope.js new file mode 100644 index 0000000..421c26f --- /dev/null +++ b/packages/backend/src/apps/google-calendar/common/auth-scope.js @@ -0,0 +1,7 @@ +const authScope = [ + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +export default authScope; diff --git a/packages/backend/src/apps/google-calendar/common/get-current-user.js b/packages/backend/src/apps/google-calendar/common/get-current-user.js new file mode 100644 index 0000000..2663ad2 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses' + ); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/google-calendar/dynamic-data/index.js b/packages/backend/src/apps/google-calendar/dynamic-data/index.js new file mode 100644 index 0000000..2cf6ba7 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listCalendars from './list-calendars/index.js'; + +export default [listCalendars]; diff --git a/packages/backend/src/apps/google-calendar/dynamic-data/list-calendars/index.js b/packages/backend/src/apps/google-calendar/dynamic-data/list-calendars/index.js new file mode 100644 index 0000000..afed03f --- /dev/null +++ b/packages/backend/src/apps/google-calendar/dynamic-data/list-calendars/index.js @@ -0,0 +1,32 @@ +export default { + name: 'List calendars', + key: 'listCalendars', + + async run($) { + const drives = { + data: [], + }; + + const params = { + pageToken: undefined, + }; + + do { + const { data } = await $.http.get(`/v3/users/me/calendarList`, { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.items) { + for (const calendar of data.items) { + drives.data.push({ + value: calendar.id, + name: calendar.summary, + }); + } + } + } while (params.pageToken); + + return drives; + }, +}; diff --git a/packages/backend/src/apps/google-calendar/index.js b/packages/backend/src/apps/google-calendar/index.js new file mode 100644 index 0000000..89ef097 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Google Calendar', + key: 'google-calendar', + baseUrl: 'https://calendar.google.com', + apiBaseUrl: 'https://www.googleapis.com/calendar', + iconUrl: '{BASE_URL}/apps/google-calendar/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/google-calendar/connection', + primaryColor: '#448AFF', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/google-calendar/triggers/index.js b/packages/backend/src/apps/google-calendar/triggers/index.js new file mode 100644 index 0000000..bb51066 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/triggers/index.js @@ -0,0 +1,4 @@ +import newCalendar from './new-calendar/index.js'; +import newEvent from './new-event/index.js'; + +export default [newCalendar, newEvent]; diff --git a/packages/backend/src/apps/google-calendar/triggers/new-calendar/index.js b/packages/backend/src/apps/google-calendar/triggers/new-calendar/index.js new file mode 100644 index 0000000..53ea731 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/triggers/new-calendar/index.js @@ -0,0 +1,34 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New calendar', + key: 'newCalendar', + pollInterval: 15, + description: 'Triggers when a new calendar is created.', + arguments: [], + + async run($) { + const params = { + pageToken: undefined, + maxResults: 250, + }; + + do { + const { data } = await $.http.get('/v3/users/me/calendarList', { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.items?.length) { + for (const calendar of data.items.reverse()) { + $.pushTriggerItem({ + raw: calendar, + meta: { + internalId: calendar.etag, + }, + }); + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/google-calendar/triggers/new-event/index.js b/packages/backend/src/apps/google-calendar/triggers/new-event/index.js new file mode 100644 index 0000000..1c01219 --- /dev/null +++ b/packages/backend/src/apps/google-calendar/triggers/new-event/index.js @@ -0,0 +1,55 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New event', + key: 'newEvent', + pollInterval: 15, + description: 'Triggers when a new event is created.', + arguments: [ + { + label: 'Calendar', + key: 'calendarId', + type: 'dropdown', + required: true, + description: '', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCalendars', + }, + ], + }, + }, + ], + + async run($) { + const calendarId = $.step.parameters.calendarId; + + const params = { + pageToken: undefined, + orderBy: 'updated', + }; + + do { + const { data } = await $.http.get(`/v3/calendars/${calendarId}/events`, { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.items?.length) { + for (const event of data.items.reverse()) { + $.pushTriggerItem({ + raw: event, + meta: { + internalId: event.etag, + }, + }); + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/google-drive/assets/favicon.svg b/packages/backend/src/apps/google-drive/assets/favicon.svg new file mode 100644 index 0000000..a8cefd5 --- /dev/null +++ b/packages/backend/src/apps/google-drive/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/google-drive/auth/generate-auth-url.js b/packages/backend/src/apps/google-drive/auth/generate-auth-url.js new file mode 100644 index 0000000..c972ae1 --- /dev/null +++ b/packages/backend/src/apps/google-drive/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + prompt: 'select_account', + scope: authScope.join(' '), + response_type: 'code', + access_type: 'offline', + }); + + const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/google-drive/auth/index.js b/packages/backend/src/apps/google-drive/auth/index.js new file mode 100644 index 0000000..9dd5471 --- /dev/null +++ b/packages/backend/src/apps/google-drive/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/google-drive/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Google Cloud, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/google-drive/auth/is-still-verified.js b/packages/backend/src/apps/google-drive/auth/is-still-verified.js new file mode 100644 index 0000000..68f4d7d --- /dev/null +++ b/packages/backend/src/apps/google-drive/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.resourceName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/google-drive/auth/refresh-token.js b/packages/backend/src/apps/google-drive/auth/refresh-token.js new file mode 100644 index 0000000..7c5b702 --- /dev/null +++ b/packages/backend/src/apps/google-drive/auth/refresh-token.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://oauth2.googleapis.com/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/google-drive/auth/verify-credentials.js b/packages/backend/src/apps/google-drive/auth/verify-credentials.js new file mode 100644 index 0000000..a636b72 --- /dev/null +++ b/packages/backend/src/apps/google-drive/auth/verify-credentials.js @@ -0,0 +1,42 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + const { displayName } = currentUser.names.find( + (name) => name.metadata.primary + ); + const { value: email } = currentUser.emailAddresses.find( + (emailAddress) => emailAddress.metadata.primary + ); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + resourceName: currentUser.resourceName, + screenName: `${displayName} - ${email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/google-drive/common/add-auth-header.js b/packages/backend/src/apps/google-drive/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/google-drive/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/google-drive/common/auth-scope.js b/packages/backend/src/apps/google-drive/common/auth-scope.js new file mode 100644 index 0000000..5bd6053 --- /dev/null +++ b/packages/backend/src/apps/google-drive/common/auth-scope.js @@ -0,0 +1,7 @@ +const authScope = [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +export default authScope; diff --git a/packages/backend/src/apps/google-drive/common/get-current-user.js b/packages/backend/src/apps/google-drive/common/get-current-user.js new file mode 100644 index 0000000..2663ad2 --- /dev/null +++ b/packages/backend/src/apps/google-drive/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses' + ); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/google-drive/dynamic-data/index.js b/packages/backend/src/apps/google-drive/dynamic-data/index.js new file mode 100644 index 0000000..96e1d64 --- /dev/null +++ b/packages/backend/src/apps/google-drive/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listFolders from './list-folders/index.js'; +import listDrives from './list-drives/index.js'; + +export default [listFolders, listDrives]; diff --git a/packages/backend/src/apps/google-drive/dynamic-data/list-drives/index.js b/packages/backend/src/apps/google-drive/dynamic-data/list-drives/index.js new file mode 100644 index 0000000..2454433 --- /dev/null +++ b/packages/backend/src/apps/google-drive/dynamic-data/list-drives/index.js @@ -0,0 +1,31 @@ +export default { + name: 'List drives', + key: 'listDrives', + + async run($) { + const drives = { + data: [{ value: null, name: 'My Google Drive' }], + }; + + const params = { + pageSize: 100, + pageToken: undefined, + }; + + do { + const { data } = await $.http.get(`/v3/drives`, { params }); + params.pageToken = data.nextPageToken; + + if (data.drives) { + for (const drive of data.drives) { + drives.data.push({ + value: drive.id, + name: drive.name, + }); + } + } + } while (params.pageToken); + + return drives; + }, +}; diff --git a/packages/backend/src/apps/google-drive/dynamic-data/list-folders/index.js b/packages/backend/src/apps/google-drive/dynamic-data/list-folders/index.js new file mode 100644 index 0000000..c52817b --- /dev/null +++ b/packages/backend/src/apps/google-drive/dynamic-data/list-folders/index.js @@ -0,0 +1,43 @@ +export default { + name: 'List folders', + key: 'listFolders', + + async run($) { + const folders = { + data: [], + }; + + const params = { + q: `mimeType='application/vnd.google-apps.folder'`, + orderBy: 'createdTime desc', + pageToken: undefined, + pageSize: 1000, + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get( + `https://www.googleapis.com/drive/v3/files`, + { + params, + } + ); + params.pageToken = data.nextPageToken; + + for (const file of data.files) { + folders.data.push({ + value: file.id, + name: file.name, + }); + } + } while (params.pageToken); + + return folders; + }, +}; diff --git a/packages/backend/src/apps/google-drive/index.js b/packages/backend/src/apps/google-drive/index.js new file mode 100644 index 0000000..cde0949 --- /dev/null +++ b/packages/backend/src/apps/google-drive/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Google Drive', + key: 'google-drive', + baseUrl: 'https://drive.google.com', + apiBaseUrl: 'https://www.googleapis.com/drive', + iconUrl: '{BASE_URL}/apps/google-drive/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/google-drive/connection', + primaryColor: '#1FA463', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/google-drive/triggers/index.js b/packages/backend/src/apps/google-drive/triggers/index.js new file mode 100644 index 0000000..1f60196 --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/index.js @@ -0,0 +1,6 @@ +import newFiles from './new-files/index.js'; +import newFilesInFolder from './new-files-in-folder/index.js'; +import newFolders from './new-folders/index.js'; +import updatedFiles from './updated-files/index.js'; + +export default [newFiles, newFilesInFolder, newFolders, updatedFiles]; diff --git a/packages/backend/src/apps/google-drive/triggers/new-files-in-folder/index.js b/packages/backend/src/apps/google-drive/triggers/new-files-in-folder/index.js new file mode 100644 index 0000000..182a08e --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/new-files-in-folder/index.js @@ -0,0 +1,59 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newFilesInFolder from './new-files-in-folder.js'; + +export default defineTrigger({ + name: 'New files in folder', + key: 'newFilesInFolder', + pollInterval: 15, + description: + 'Triggers when a new file is added directly to a specific folder (but not its subfolder).', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your file resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.driveId'], + description: + 'Check a specific folder for new files. Please note: new files added to subfolders inside the folder you choose here will NOT trigger this flow. Defaults to the top-level folder if none is picked.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + ], + + async run($) { + await newFilesInFolder($); + }, +}); diff --git a/packages/backend/src/apps/google-drive/triggers/new-files-in-folder/new-files-in-folder.js b/packages/backend/src/apps/google-drive/triggers/new-files-in-folder/new-files-in-folder.js new file mode 100644 index 0000000..fcad7f8 --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/new-files-in-folder/new-files-in-folder.js @@ -0,0 +1,40 @@ +const newFilesInFolder = async ($) => { + let q = "mimeType!='application/vnd.google-apps.folder'"; + if ($.step.parameters.folderId) { + q += ` and '${$.step.parameters.folderId}' in parents`; + } else { + q += ` and parents in 'root'`; + } + const params = { + pageToken: undefined, + orderBy: 'createdTime desc', + fields: '*', + pageSize: 1000, + q, + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get(`/v3/files`, { params }); + params.pageToken = data.nextPageToken; + + if (data.files?.length) { + for (const file of data.files) { + $.pushTriggerItem({ + raw: file, + meta: { + internalId: file.id, + }, + }); + } + } + } while (params.pageToken); +}; + +export default newFilesInFolder; diff --git a/packages/backend/src/apps/google-drive/triggers/new-files/index.js b/packages/backend/src/apps/google-drive/triggers/new-files/index.js new file mode 100644 index 0000000..f5533ae --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/new-files/index.js @@ -0,0 +1,34 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newFiles from './new-files.js'; + +export default defineTrigger({ + name: 'New files', + key: 'newFiles', + pollInterval: 15, + description: 'Triggers when any new file is added (inside of any folder).', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your file resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + ], + + async run($) { + await newFiles($); + }, +}); diff --git a/packages/backend/src/apps/google-drive/triggers/new-files/new-files.js b/packages/backend/src/apps/google-drive/triggers/new-files/new-files.js new file mode 100644 index 0000000..dc0fce8 --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/new-files/new-files.js @@ -0,0 +1,34 @@ +const newFiles = async ($) => { + const params = { + pageToken: undefined, + orderBy: 'createdTime desc', + fields: '*', + pageSize: 1000, + q: `mimeType!='application/vnd.google-apps.folder'`, + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get('/v3/files', { params }); + params.pageToken = data.nextPageToken; + + if (data.files?.length) { + for (const file of data.files) { + $.pushTriggerItem({ + raw: file, + meta: { + internalId: file.id, + }, + }); + } + } + } while (params.pageToken); +}; + +export default newFiles; diff --git a/packages/backend/src/apps/google-drive/triggers/new-folders/index.js b/packages/backend/src/apps/google-drive/triggers/new-folders/index.js new file mode 100644 index 0000000..d2b8c7e --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/new-folders/index.js @@ -0,0 +1,59 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newFolders from './new-folders.js'; + +export default defineTrigger({ + name: 'New folders', + key: 'newFolders', + pollInterval: 15, + description: + 'Triggers when a new folder is added directly to a specific folder (but not its subfolder).', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your file resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.driveId'], + description: + 'Check a specific folder for new subfolders. Please note: new folders added to subfolders inside the folder you choose here will NOT trigger this flow. Defaults to the top-level folder if none is picked.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + ], + + async run($) { + await newFolders($); + }, +}); diff --git a/packages/backend/src/apps/google-drive/triggers/new-folders/new-folders.js b/packages/backend/src/apps/google-drive/triggers/new-folders/new-folders.js new file mode 100644 index 0000000..d371dfe --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/new-folders/new-folders.js @@ -0,0 +1,41 @@ +const newFolders = async ($) => { + let q = "mimeType='application/vnd.google-apps.folder'"; + if ($.step.parameters.folderId) { + q += ` and '${$.step.parameters.folderId}' in parents`; + } else { + q += ` and parents in 'root'`; + } + + const params = { + pageToken: undefined, + orderBy: 'createdTime desc', + fields: '*', + pageSize: 1000, + q, + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get(`/v3/files`, { params }); + params.pageToken = data.nextPageToken; + + if (data.files?.length) { + for (const file of data.files) { + $.pushTriggerItem({ + raw: file, + meta: { + internalId: file.id, + }, + }); + } + } + } while (params.pageToken); +}; + +export default newFolders; diff --git a/packages/backend/src/apps/google-drive/triggers/updated-files/index.js b/packages/backend/src/apps/google-drive/triggers/updated-files/index.js new file mode 100644 index 0000000..18d4333 --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/updated-files/index.js @@ -0,0 +1,76 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import updatedFiles from './updated-files.js'; + +export default defineTrigger({ + name: 'Updated files', + key: 'updatedFiles', + pollInterval: 15, + description: + 'Triggers when a file is updated in a specific folder (but not its subfolder).', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your file resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Folder', + key: 'folderId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.driveId'], + description: + 'Check a specific folder for updated files. Please note: files located in subfolders of the folder you choose here will NOT trigger this flow. Defaults to the top-level folder if none is picked.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFolders', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + { + label: 'Include Deleted', + key: 'includeDeleted', + type: 'dropdown', + required: true, + value: true, + description: 'Should this trigger also on files that are deleted?', + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + ], + + async run($) { + await updatedFiles($); + }, +}); diff --git a/packages/backend/src/apps/google-drive/triggers/updated-files/updated-files.js b/packages/backend/src/apps/google-drive/triggers/updated-files/updated-files.js new file mode 100644 index 0000000..ab5cad1 --- /dev/null +++ b/packages/backend/src/apps/google-drive/triggers/updated-files/updated-files.js @@ -0,0 +1,45 @@ +const updatedFiles = async ($) => { + let q = `mimeType!='application/vnd.google-apps.folder'`; + if ($.step.parameters.includeDeleted === false) { + q += ` and trashed=${$.step.parameters.includeDeleted}`; + } + + if ($.step.parameters.folderId) { + q += ` and '${$.step.parameters.folderId}' in parents`; + } else { + q += ` and parents in 'root'`; + } + + const params = { + pageToken: undefined, + orderBy: 'modifiedTime desc', + fields: '*', + pageSize: 1000, + q, + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get(`/v3/files`, { params }); + params.pageToken = data.nextPageToken; + + if (data.files?.length) { + for (const file of data.files) { + $.pushTriggerItem({ + raw: file, + meta: { + internalId: `${file.id}-${file.modifiedTime}`, + }, + }); + } + } + } while (params.pageToken); +}; + +export default updatedFiles; diff --git a/packages/backend/src/apps/google-forms/assets/favicon.svg b/packages/backend/src/apps/google-forms/assets/favicon.svg new file mode 100644 index 0000000..5413d43 --- /dev/null +++ b/packages/backend/src/apps/google-forms/assets/favicon.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/backend/src/apps/google-forms/auth/generate-auth-url.js b/packages/backend/src/apps/google-forms/auth/generate-auth-url.js new file mode 100644 index 0000000..c972ae1 --- /dev/null +++ b/packages/backend/src/apps/google-forms/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + prompt: 'select_account', + scope: authScope.join(' '), + response_type: 'code', + access_type: 'offline', + }); + + const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/google-forms/auth/index.js b/packages/backend/src/apps/google-forms/auth/index.js new file mode 100644 index 0000000..dc57085 --- /dev/null +++ b/packages/backend/src/apps/google-forms/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/google-forms/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Google Cloud, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/google-forms/auth/is-still-verified.js b/packages/backend/src/apps/google-forms/auth/is-still-verified.js new file mode 100644 index 0000000..68f4d7d --- /dev/null +++ b/packages/backend/src/apps/google-forms/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.resourceName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/google-forms/auth/refresh-token.js b/packages/backend/src/apps/google-forms/auth/refresh-token.js new file mode 100644 index 0000000..7c5b702 --- /dev/null +++ b/packages/backend/src/apps/google-forms/auth/refresh-token.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://oauth2.googleapis.com/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/google-forms/auth/verify-credentials.js b/packages/backend/src/apps/google-forms/auth/verify-credentials.js new file mode 100644 index 0000000..a636b72 --- /dev/null +++ b/packages/backend/src/apps/google-forms/auth/verify-credentials.js @@ -0,0 +1,42 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + const { displayName } = currentUser.names.find( + (name) => name.metadata.primary + ); + const { value: email } = currentUser.emailAddresses.find( + (emailAddress) => emailAddress.metadata.primary + ); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + resourceName: currentUser.resourceName, + screenName: `${displayName} - ${email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/google-forms/common/add-auth-header.js b/packages/backend/src/apps/google-forms/common/add-auth-header.js new file mode 100644 index 0000000..6c86424 --- /dev/null +++ b/packages/backend/src/apps/google-forms/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.headers && $.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/google-forms/common/auth-scope.js b/packages/backend/src/apps/google-forms/common/auth-scope.js new file mode 100644 index 0000000..655ae74 --- /dev/null +++ b/packages/backend/src/apps/google-forms/common/auth-scope.js @@ -0,0 +1,9 @@ +const authScope = [ + 'https://www.googleapis.com/auth/forms.body.readonly', + 'https://www.googleapis.com/auth/forms.responses.readonly', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/userinfo.email', + 'profile', +]; + +export default authScope; diff --git a/packages/backend/src/apps/google-forms/common/get-current-user.js b/packages/backend/src/apps/google-forms/common/get-current-user.js new file mode 100644 index 0000000..2663ad2 --- /dev/null +++ b/packages/backend/src/apps/google-forms/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses' + ); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/google-forms/dynamic-data/index.js b/packages/backend/src/apps/google-forms/dynamic-data/index.js new file mode 100644 index 0000000..0a58430 --- /dev/null +++ b/packages/backend/src/apps/google-forms/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listForms from './list-forms/index.js'; + +export default [listForms]; diff --git a/packages/backend/src/apps/google-forms/dynamic-data/list-forms/index.js b/packages/backend/src/apps/google-forms/dynamic-data/list-forms/index.js new file mode 100644 index 0000000..0025081 --- /dev/null +++ b/packages/backend/src/apps/google-forms/dynamic-data/list-forms/index.js @@ -0,0 +1,33 @@ +export default { + name: 'List forms', + key: 'listForms', + + async run($) { + const forms = { + data: [], + }; + + const params = { + q: `mimeType='application/vnd.google-apps.form'`, + spaces: 'drive', + pageToken: undefined, + }; + + do { + const { data } = await $.http.get( + `https://www.googleapis.com/drive/v3/files`, + { params } + ); + params.pageToken = data.nextPageToken; + + for (const file of data.files) { + forms.data.push({ + value: file.id, + name: file.name, + }); + } + } while (params.pageToken); + + return forms; + }, +}; diff --git a/packages/backend/src/apps/google-forms/index.js b/packages/backend/src/apps/google-forms/index.js new file mode 100644 index 0000000..e3b6d89 --- /dev/null +++ b/packages/backend/src/apps/google-forms/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Google Forms', + key: 'google-forms', + baseUrl: 'https://docs.google.com/forms', + apiBaseUrl: 'https://forms.googleapis.com', + iconUrl: '{BASE_URL}/apps/google-forms/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/google-forms/connection', + primaryColor: '#673AB7', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/google-forms/triggers/index.js b/packages/backend/src/apps/google-forms/triggers/index.js new file mode 100644 index 0000000..41e9048 --- /dev/null +++ b/packages/backend/src/apps/google-forms/triggers/index.js @@ -0,0 +1,3 @@ +import newFormResponses from './new-form-responses/index.js'; + +export default [newFormResponses]; diff --git a/packages/backend/src/apps/google-forms/triggers/new-form-responses/index.js b/packages/backend/src/apps/google-forms/triggers/new-form-responses/index.js new file mode 100644 index 0000000..ac16701 --- /dev/null +++ b/packages/backend/src/apps/google-forms/triggers/new-form-responses/index.js @@ -0,0 +1,33 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newFormResponses from './new-form-responses.js'; + +export default defineTrigger({ + name: 'New form responses', + key: 'newFormResponses', + pollInterval: 15, + description: 'Triggers when a new form response is submitted.', + arguments: [ + { + label: 'Form', + key: 'formId', + type: 'dropdown', + required: true, + description: 'Pick a form to receive form responses.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listForms', + }, + ], + }, + }, + ], + + async run($) { + await newFormResponses($); + }, +}); diff --git a/packages/backend/src/apps/google-forms/triggers/new-form-responses/new-form-responses.js b/packages/backend/src/apps/google-forms/triggers/new-form-responses/new-form-responses.js new file mode 100644 index 0000000..26487b4 --- /dev/null +++ b/packages/backend/src/apps/google-forms/triggers/new-form-responses/new-form-responses.js @@ -0,0 +1,26 @@ +const newResponses = async ($) => { + const params = { + pageToken: undefined, + }; + + do { + const pathname = `/v1/forms/${$.step.parameters.formId}/responses`; + const { data } = await $.http.get(pathname, { params }); + params.pageToken = data.nextPageToken; + + if (data.responses?.length) { + for (const formResponse of data.responses) { + const dataItem = { + raw: formResponse, + meta: { + internalId: formResponse.responseId, + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (params.pageToken); +}; + +export default newResponses; diff --git a/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.js b/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.js new file mode 100644 index 0000000..be18a16 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.js @@ -0,0 +1,134 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create spreadsheet row', + key: 'createSpreadsheetRow', + description: 'Creates a new row in a specified spreadsheet.', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Spreadsheet', + key: 'spreadsheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.driveId'], + description: 'The spreadsheets in your Google Drive.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpreadsheets', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + { + label: 'Worksheet', + key: 'worksheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.spreadsheetId'], + description: 'The worksheets in your selected spreadsheet.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorksheets', + }, + { + name: 'parameters.spreadsheetId', + value: '{parameters.spreadsheetId}', + }, + ], + }, + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listSheetHeaders', + }, + { + name: 'parameters.worksheetId', + value: '{parameters.worksheetId}', + }, + { + name: 'parameters.spreadsheetId', + value: '{parameters.spreadsheetId}', + }, + ], + }, + }, + ], + + async run($) { + const { + data: { sheets }, + } = await $.http.get(`/v4/spreadsheets/${$.step.parameters.spreadsheetId}`); + + const selectedSheet = sheets.find( + (sheet) => sheet.properties.sheetId === $.step.parameters.worksheetId + ); + + const sheetName = selectedSheet.properties.title; + + const range = sheetName; + + const dataValues = Object.entries($.step.parameters) + .filter((entry) => entry[0].startsWith('header-')) + .map((value) => value[1]); + + const values = [dataValues]; + + const params = { + valueInputOption: 'USER_ENTERED', + insertDataOption: 'INSERT_ROWS', + includeValuesInResponse: true, + }; + + const body = { + majorDimension: 'ROWS', + range, + values, + }; + + const { data } = await $.http.post( + `/v4/spreadsheets/${$.step.parameters.spreadsheetId}/values/${range}:append`, + body, + { params } + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/google-sheets/actions/create-spreadsheet/index.js b/packages/backend/src/apps/google-sheets/actions/create-spreadsheet/index.js new file mode 100644 index 0000000..bf26920 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/actions/create-spreadsheet/index.js @@ -0,0 +1,100 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create spreadsheet', + key: 'createSpreadsheet', + description: + 'Create a blank spreadsheet or duplicate an existing spreadsheet. Optionally, provide headers.', + arguments: [ + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Spreadsheet to copy', + key: 'spreadsheetId', + type: 'dropdown', + required: false, + description: 'Choose a spreadsheet to copy its data.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpreadsheets', + }, + ], + }, + }, + { + label: 'Headers', + key: 'headers', + type: 'dynamic', + required: false, + description: + 'These headers are ignored if "Spreadsheet to Copy" is selected.', + fields: [ + { + label: 'Header', + key: 'header', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + if ($.step.parameters.spreadsheetId) { + const body = { name: $.step.parameters.title }; + + const { data } = await $.http.post( + `https://www.googleapis.com/drive/v3/files/${$.step.parameters.spreadsheetId}/copy`, + body + ); + + $.setActionItem({ + raw: data, + }); + } else { + const headers = $.step.parameters.headers; + const values = headers.map((entry) => entry.header); + + const spreadsheetBody = { + properties: { + title: $.step.parameters.title, + }, + sheets: [ + { + data: [ + { + startRow: 0, + startColumn: 0, + rowData: [ + { + values: values.map((header) => ({ + userEnteredValue: { stringValue: header }, + })), + }, + ], + }, + ], + }, + ], + }; + + const { data } = await $.http.post('/v4/spreadsheets', spreadsheetBody); + + $.setActionItem({ + raw: data, + }); + } + }, +}); diff --git a/packages/backend/src/apps/google-sheets/actions/create-worksheet/index.js b/packages/backend/src/apps/google-sheets/actions/create-worksheet/index.js new file mode 100644 index 0000000..ceed6f2 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/actions/create-worksheet/index.js @@ -0,0 +1,171 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create worksheet', + key: 'createWorksheet', + description: + 'Create a blank worksheet with a title. Optionally, provide headers.', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Spreadsheet', + key: 'spreadsheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.driveId'], + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpreadsheets', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Headers', + key: 'headers', + type: 'dynamic', + required: false, + fields: [ + { + label: 'Header', + key: 'header', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Overwrite', + key: 'overwrite', + type: 'dropdown', + required: false, + value: false, + description: + 'If a worksheet with the specified title exists, its content would be lost. Please, use with caution.', + variables: true, + options: [ + { + label: 'Yes', + value: 'true', + }, + { + label: 'No', + value: 'false', + }, + ], + }, + ], + + async run($) { + const { + data: { sheets }, + } = await $.http.get(`/v4/spreadsheets/${$.step.parameters.spreadsheetId}`); + + const selectedSheet = sheets.find( + (sheet) => sheet.properties.title === $.step.parameters.title + ); + const headers = $.step.parameters.headers; + const values = headers.map((entry) => entry.header); + + const body = { + requests: [ + { + addSheet: { + properties: { + title: $.step.parameters.title, + }, + }, + }, + ], + }; + + if ($.step.parameters.overwrite === 'true' && selectedSheet) { + body.requests.unshift({ + deleteSheet: { + sheetId: selectedSheet.properties.sheetId, + }, + }); + } + + const { data } = await $.http.post( + `https://sheets.googleapis.com/v4/spreadsheets/${$.step.parameters.spreadsheetId}:batchUpdate`, + body + ); + + if (values.length) { + const body = { + requests: [ + { + updateCells: { + rows: [ + { + values: values.map((header) => ({ + userEnteredValue: { stringValue: header }, + })), + }, + ], + fields: '*', + start: { + sheetId: + data.replies[data.replies.length - 1].addSheet.properties + .sheetId, + rowIndex: 0, + columnIndex: 0, + }, + }, + }, + ], + }; + + const { data: response } = await $.http.post( + `https://sheets.googleapis.com/v4/spreadsheets/${$.step.parameters.spreadsheetId}:batchUpdate`, + body + ); + + $.setActionItem({ + raw: response, + }); + return; + } + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/google-sheets/actions/find-worksheet/index.js b/packages/backend/src/apps/google-sheets/actions/find-worksheet/index.js new file mode 100644 index 0000000..b739096 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/actions/find-worksheet/index.js @@ -0,0 +1,175 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find worksheet', + key: 'findWorksheet', + description: + 'Finds a worksheet by title. Optionally, create a worksheet if none are found.', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Spreadsheet', + key: 'spreadsheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.driveId'], + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpreadsheets', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: + 'The worksheet title needs to match exactly, and the search is case-sensitive.', + variables: true, + }, + { + label: 'Create worksheet if none are found.', + key: 'createWorksheet', + type: 'dropdown', + required: false, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listCreateWorksheetFields', + }, + { + name: 'parameters.createWorksheet', + value: '{parameters.createWorksheet}', + }, + ], + }, + }, + ], + + async run($) { + const createWorksheet = $.step.parameters.createWorksheet; + + async function findWorksheet() { + const { + data: { sheets }, + } = await $.http.get(`/v4/spreadsheets/${$.step.parameters.spreadsheetId}`); + + const selectedSheet = sheets.find( + (sheet) => sheet.properties.title === $.step.parameters.title + ); + + return selectedSheet; + } + + const selectedSheet = await findWorksheet(); + + if (selectedSheet) { + $.setActionItem({ + raw: selectedSheet, + }); + + return; + } + + if (createWorksheet) { + const headers = $.step.parameters.headers; + const headerValues = headers.map((entry) => entry.header); + + const body = { + requests: [ + { + addSheet: { + properties: { + title: $.step.parameters.title, + }, + }, + }, + ], + }; + + const { data } = await $.http.post( + `/v4/spreadsheets/${$.step.parameters.spreadsheetId}:batchUpdate`, + body + ); + + if (headerValues.length) { + const body = { + requests: [ + { + updateCells: { + rows: [ + { + values: headerValues.map((header) => ({ + userEnteredValue: { stringValue: header }, + })), + }, + ], + fields: '*', + start: { + sheetId: + data.replies[data.replies.length - 1].addSheet.properties + .sheetId, + rowIndex: 0, + columnIndex: 0, + }, + }, + }, + ], + }; + + await $.http.post( + `/v4/spreadsheets/${$.step.parameters.spreadsheetId}:batchUpdate`, + body + ); + + const createdSheet = await findWorksheet(); + + $.setActionItem({ + raw: createdSheet, + }); + + return; + } + } + + $.setActionItem({ + raw: null, + }); + }, +}); diff --git a/packages/backend/src/apps/google-sheets/actions/index.js b/packages/backend/src/apps/google-sheets/actions/index.js new file mode 100644 index 0000000..053143c --- /dev/null +++ b/packages/backend/src/apps/google-sheets/actions/index.js @@ -0,0 +1,11 @@ +import createSpreadsheet from './create-spreadsheet/index.js'; +import createSpreadsheetRow from './create-spreadsheet-row/index.js'; +import createWorksheet from './create-worksheet/index.js'; +import findWorksheet from './find-worksheet/index.js'; + +export default [ + createSpreadsheet, + createSpreadsheetRow, + createWorksheet, + findWorksheet, +]; diff --git a/packages/backend/src/apps/google-sheets/assets/favicon.svg b/packages/backend/src/apps/google-sheets/assets/favicon.svg new file mode 100644 index 0000000..bd5d938 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/assets/favicon.svg @@ -0,0 +1,89 @@ + + + + Sheets-icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/google-sheets/auth/generate-auth-url.js b/packages/backend/src/apps/google-sheets/auth/generate-auth-url.js new file mode 100644 index 0000000..c972ae1 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + prompt: 'select_account', + scope: authScope.join(' '), + response_type: 'code', + access_type: 'offline', + }); + + const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/google-sheets/auth/index.js b/packages/backend/src/apps/google-sheets/auth/index.js new file mode 100644 index 0000000..45ea405 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/google-sheets/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Google Cloud, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/google-sheets/auth/is-still-verified.js b/packages/backend/src/apps/google-sheets/auth/is-still-verified.js new file mode 100644 index 0000000..68f4d7d --- /dev/null +++ b/packages/backend/src/apps/google-sheets/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.resourceName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/google-sheets/auth/refresh-token.js b/packages/backend/src/apps/google-sheets/auth/refresh-token.js new file mode 100644 index 0000000..7c5b702 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/auth/refresh-token.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://oauth2.googleapis.com/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/google-sheets/auth/verify-credentials.js b/packages/backend/src/apps/google-sheets/auth/verify-credentials.js new file mode 100644 index 0000000..a636b72 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/auth/verify-credentials.js @@ -0,0 +1,42 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + const { displayName } = currentUser.names.find( + (name) => name.metadata.primary + ); + const { value: email } = currentUser.emailAddresses.find( + (emailAddress) => emailAddress.metadata.primary + ); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + resourceName: currentUser.resourceName, + screenName: `${displayName} - ${email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/google-sheets/common/add-auth-header.js b/packages/backend/src/apps/google-sheets/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/google-sheets/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/google-sheets/common/auth-scope.js b/packages/backend/src/apps/google-sheets/common/auth-scope.js new file mode 100644 index 0000000..6c22818 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/common/auth-scope.js @@ -0,0 +1,8 @@ +const authScope = [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +export default authScope; diff --git a/packages/backend/src/apps/google-sheets/common/get-current-user.js b/packages/backend/src/apps/google-sheets/common/get-current-user.js new file mode 100644 index 0000000..2663ad2 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses' + ); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/google-sheets/dynamic-data/index.js b/packages/backend/src/apps/google-sheets/dynamic-data/index.js new file mode 100644 index 0000000..e0d6efa --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-data/index.js @@ -0,0 +1,5 @@ +import listDrives from './list-drives/index.js'; +import listSpreadsheets from './list-spreadsheets/index.js'; +import listWorksheets from './list-worksheets/index.js'; + +export default [listDrives, listSpreadsheets, listWorksheets]; diff --git a/packages/backend/src/apps/google-sheets/dynamic-data/list-drives/index.js b/packages/backend/src/apps/google-sheets/dynamic-data/list-drives/index.js new file mode 100644 index 0000000..6d34bb7 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-data/list-drives/index.js @@ -0,0 +1,34 @@ +export default { + name: 'List drives', + key: 'listDrives', + + async run($) { + const drives = { + data: [{ value: null, name: 'My Google Drive' }], + }; + + const params = { + pageSize: 100, + pageToken: undefined, + }; + + do { + const { data } = await $.http.get( + `https://www.googleapis.com/drive/v3/drives`, + { params } + ); + params.pageToken = data.nextPageToken; + + if (data.drives) { + for (const drive of data.drives) { + drives.data.push({ + value: drive.id, + name: drive.name, + }); + } + } + } while (params.pageToken); + + return drives; + }, +}; diff --git a/packages/backend/src/apps/google-sheets/dynamic-data/list-spreadsheets/index.js b/packages/backend/src/apps/google-sheets/dynamic-data/list-spreadsheets/index.js new file mode 100644 index 0000000..97568c8 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-data/list-spreadsheets/index.js @@ -0,0 +1,43 @@ +export default { + name: 'List spreadsheets', + key: 'listSpreadsheets', + + async run($) { + const spreadsheets = { + data: [], + }; + + const params = { + q: `mimeType='application/vnd.google-apps.spreadsheet'`, + pageSize: 100, + pageToken: undefined, + orderBy: 'createdTime desc', + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get( + `https://www.googleapis.com/drive/v3/files`, + { params } + ); + params.pageToken = data.nextPageToken; + + if (data.files?.length) { + for (const file of data.files) { + spreadsheets.data.push({ + value: file.id, + name: file.name, + }); + } + } + } while (params.pageToken); + + return spreadsheets; + }, +}; diff --git a/packages/backend/src/apps/google-sheets/dynamic-data/list-worksheets/index.js b/packages/backend/src/apps/google-sheets/dynamic-data/list-worksheets/index.js new file mode 100644 index 0000000..aee5692 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-data/list-worksheets/index.js @@ -0,0 +1,38 @@ +export default { + name: 'List worksheets', + key: 'listWorksheets', + + async run($) { + const spreadsheetId = $.step.parameters.spreadsheetId; + + const worksheets = { + data: [], + }; + + if (!spreadsheetId) { + return worksheets; + } + + const params = { + pageToken: undefined, + }; + + do { + const { data } = await $.http.get(`/v4/spreadsheets/${spreadsheetId}`, { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.sheets?.length) { + for (const sheet of data.sheets) { + worksheets.data.push({ + value: sheet.properties.sheetId, + name: sheet.properties.title, + }); + } + } + } while (params.pageToken); + + return worksheets; + }, +}; diff --git a/packages/backend/src/apps/google-sheets/dynamic-fields/index.js b/packages/backend/src/apps/google-sheets/dynamic-fields/index.js new file mode 100644 index 0000000..0a41361 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-fields/index.js @@ -0,0 +1,4 @@ +import listSheetHeaders from './list-sheet-headers/index.js'; +import listCreateWorksheetFields from './list-create-worksheet-fields/index.js'; + +export default [listSheetHeaders, listCreateWorksheetFields]; diff --git a/packages/backend/src/apps/google-sheets/dynamic-fields/list-create-worksheet-fields/index.js b/packages/backend/src/apps/google-sheets/dynamic-fields/list-create-worksheet-fields/index.js new file mode 100644 index 0000000..46f7f89 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-fields/list-create-worksheet-fields/index.js @@ -0,0 +1,26 @@ +export default { + name: 'List create worksheet fields', + key: 'listCreateWorksheetFields', + + async run($) { + if ($.step.parameters.createWorksheet) { + return [ + { + label: 'Headers', + key: 'headers', + type: 'dynamic', + required: false, + fields: [ + { + label: 'Header', + key: 'header', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ]; + } + }, +}; diff --git a/packages/backend/src/apps/google-sheets/dynamic-fields/list-sheet-headers/index.js b/packages/backend/src/apps/google-sheets/dynamic-fields/list-sheet-headers/index.js new file mode 100644 index 0000000..cbae65d --- /dev/null +++ b/packages/backend/src/apps/google-sheets/dynamic-fields/list-sheet-headers/index.js @@ -0,0 +1,55 @@ +const hasValue = (value) => value !== null && value !== undefined; + +export default { + name: 'List Sheet Headers', + key: 'listSheetHeaders', + + async run($) { + if ( + !hasValue($.step.parameters.spreadsheetId) || + !hasValue($.step.parameters.worksheetId) + ) { + return; + } + + const { + data: { sheets }, + } = await $.http.get(`/v4/spreadsheets/${$.step.parameters.spreadsheetId}`); + + const selectedSheet = sheets.find( + (sheet) => sheet.properties.sheetId === $.step.parameters.worksheetId + ); + + if (!selectedSheet) return; + + const sheetName = selectedSheet.properties.title; + + const range = `${sheetName}!1:1`; + + const params = { + majorDimension: 'ROWS', + }; + + const { data } = await $.http.get( + `/v4/spreadsheets/${$.step.parameters.spreadsheetId}/values/${range}`, + { + params, + } + ); + + if (!data.values) { + return; + } + + const result = data.values[0].map((item, index) => ({ + label: item, + key: `header-${index}`, + type: 'string', + required: false, + value: item, + variables: true, + })); + + return result; + }, +}; diff --git a/packages/backend/src/apps/google-sheets/index.js b/packages/backend/src/apps/google-sheets/index.js new file mode 100644 index 0000000..d496302 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/index.js @@ -0,0 +1,24 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'Google Sheets', + key: 'google-sheets', + baseUrl: 'https://docs.google.com/spreadsheets', + apiBaseUrl: 'https://sheets.googleapis.com', + iconUrl: '{BASE_URL}/apps/google-sheets/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/google-sheets/connection', + primaryColor: '#0F9D58', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, + dynamicFields, +}); diff --git a/packages/backend/src/apps/google-sheets/triggers/index.js b/packages/backend/src/apps/google-sheets/triggers/index.js new file mode 100644 index 0000000..1d0683c --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/index.js @@ -0,0 +1,5 @@ +import newSpreadsheets from './new-spreadsheets/index.js'; +import newWorksheets from './new-worksheets/index.js'; +import newSpreadsheetRows from './new-spreadsheet-rows/index.js'; + +export default [newSpreadsheets, newWorksheets, newSpreadsheetRows]; diff --git a/packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/index.js b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/index.js new file mode 100644 index 0000000..164e79f --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/index.js @@ -0,0 +1,82 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newSpreadsheetRows from './new-spreadsheet-rows.js'; + +export default defineTrigger({ + name: 'New spreadsheet rows', + key: 'newSpreadsheetRows', + pollInterval: 15, + description: + 'Triggers when a new row is added to the bottom of a spreadsheet.', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Spreadsheet', + key: 'spreadsheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.driveId'], + description: 'The spreadsheets in your Google Drive.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpreadsheets', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + { + label: 'Worksheet', + key: 'worksheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.spreadsheetId'], + description: + 'The worksheets in your selected spreadsheet. You must have column headers.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWorksheets', + }, + { + name: 'parameters.spreadsheetId', + value: '{parameters.spreadsheetId}', + }, + ], + }, + }, + ], + + async run($) { + await newSpreadsheetRows($); + }, +}); diff --git a/packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/new-spreadsheet-rows.js b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/new-spreadsheet-rows.js new file mode 100644 index 0000000..fd4c2d6 --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheet-rows/new-spreadsheet-rows.js @@ -0,0 +1,33 @@ +const newSpreadsheetRows = async ($) => { + const { + data: { sheets }, + } = await $.http.get(`/v4/spreadsheets/${$.step.parameters.spreadsheetId}`); + + const selectedSheet = sheets.find( + (sheet) => sheet.properties.sheetId === $.step.parameters.worksheetId + ); + + if (!selectedSheet) return; + + const sheetName = selectedSheet.properties.title; + + const range = sheetName; + + const { data } = await $.http.get( + `v4/spreadsheets/${$.step.parameters.spreadsheetId}/values/${range}` + ); + + if (data.values?.length) { + for (let index = data.values.length - 1; index > 0; index--) { + const value = data.values[index]; + $.pushTriggerItem({ + raw: { row: value }, + meta: { + internalId: index.toString(), + }, + }); + } + } +}; + +export default newSpreadsheetRows; diff --git a/packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/index.js b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/index.js new file mode 100644 index 0000000..861bdee --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/index.js @@ -0,0 +1,34 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newSpreadsheets from './new-spreadsheets.js'; + +export default defineTrigger({ + name: 'New spreadsheets', + key: 'newSpreadsheets', + pollInterval: 15, + description: 'Triggers when you create a new spreadsheet.', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + ], + + async run($) { + await newSpreadsheets($); + }, +}); diff --git a/packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/new-spreadsheets.js b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/new-spreadsheets.js new file mode 100644 index 0000000..f3d2c4a --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/new-spreadsheets/new-spreadsheets.js @@ -0,0 +1,37 @@ +const newSpreadsheets = async ($) => { + const params = { + pageToken: undefined, + orderBy: 'createdTime desc', + q: `mimeType='application/vnd.google-apps.spreadsheet'`, + fields: '*', + pageSize: 1000, + driveId: $.step.parameters.driveId, + supportsAllDrives: true, + }; + + if ($.step.parameters.driveId) { + params.includeItemsFromAllDrives = true; + params.corpora = 'drive'; + } + + do { + const { data } = await $.http.get( + 'https://www.googleapis.com/drive/v3/files', + { params } + ); + params.pageToken = data.nextPageToken; + + if (data.files?.length) { + for (const file of data.files) { + $.pushTriggerItem({ + raw: file, + meta: { + internalId: file.id, + }, + }); + } + } + } while (params.pageToken); +}; + +export default newSpreadsheets; diff --git a/packages/backend/src/apps/google-sheets/triggers/new-worksheets/index.js b/packages/backend/src/apps/google-sheets/triggers/new-worksheets/index.js new file mode 100644 index 0000000..8933ade --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/new-worksheets/index.js @@ -0,0 +1,57 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newWorksheets from './new-worksheets.js'; + +export default defineTrigger({ + name: 'New worksheets', + key: 'newWorksheets', + pollInterval: 15, + description: 'Triggers when you create a new worksheet in a spreadsheet.', + arguments: [ + { + label: 'Drive', + key: 'driveId', + type: 'dropdown', + required: false, + description: + 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDrives', + }, + ], + }, + }, + { + label: 'Spreadsheet', + key: 'spreadsheetId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.driveId'], + description: 'The spreadsheets in your Google Drive.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSpreadsheets', + }, + { + name: 'parameters.driveId', + value: '{parameters.driveId}', + }, + ], + }, + }, + ], + + async run($) { + await newWorksheets($); + }, +}); diff --git a/packages/backend/src/apps/google-sheets/triggers/new-worksheets/new-worksheets.js b/packages/backend/src/apps/google-sheets/triggers/new-worksheets/new-worksheets.js new file mode 100644 index 0000000..bfc57cc --- /dev/null +++ b/packages/backend/src/apps/google-sheets/triggers/new-worksheets/new-worksheets.js @@ -0,0 +1,26 @@ +const newWorksheets = async ($) => { + const params = { + pageToken: undefined, + }; + + do { + const { data } = await $.http.get( + `/v4/spreadsheets/${$.step.parameters.spreadsheetId}`, + { params } + ); + params.pageToken = data.nextPageToken; + + if (data.sheets?.length) { + for (const sheet of data.sheets.reverse()) { + $.pushTriggerItem({ + raw: sheet, + meta: { + internalId: sheet.properties.sheetId.toString(), + }, + }); + } + } + } while (params.pageToken); +}; + +export default newWorksheets; diff --git a/packages/backend/src/apps/google-tasks/actions/create-task-list/index.js b/packages/backend/src/apps/google-tasks/actions/create-task-list/index.js new file mode 100644 index 0000000..0d2571b --- /dev/null +++ b/packages/backend/src/apps/google-tasks/actions/create-task-list/index.js @@ -0,0 +1,31 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create task list', + key: 'createTaskList', + description: 'Creates a new task list.', + arguments: [ + { + label: 'List Title', + key: 'listTitle', + type: 'string', + required: true, + description: '', + variables: true, + }, + ], + + async run($) { + const listTitle = $.step.parameters.listTitle; + + const body = { + title: listTitle, + }; + + const { data } = await $.http.post('/tasks/v1/users/@me/lists', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/google-tasks/actions/create-task/index.js b/packages/backend/src/apps/google-tasks/actions/create-task/index.js new file mode 100644 index 0000000..2b03a20 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/actions/create-task/index.js @@ -0,0 +1,70 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create task', + key: 'createTask', + description: 'Creates a new task.', + arguments: [ + { + label: 'Task List', + key: 'taskListId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTaskLists', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Notes', + key: 'notes', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Due Date', + key: 'due', + type: 'string', + required: false, + description: 'RFC 3339 timestamp.', + variables: true, + }, + ], + + async run($) { + const { taskListId, title, notes, due } = $.step.parameters; + + const body = { + title, + notes, + due, + }; + + const { data } = await $.http.post( + `/tasks/v1/lists/${taskListId}/tasks`, + body + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/google-tasks/actions/find-task/index.js b/packages/backend/src/apps/google-tasks/actions/find-task/index.js new file mode 100644 index 0000000..00c3f46 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/actions/find-task/index.js @@ -0,0 +1,57 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find task', + key: 'findTask', + description: 'Looking for a specific task.', + arguments: [ + { + label: 'Task List', + key: 'taskListId', + type: 'dropdown', + required: true, + description: 'The list to be searched.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTaskLists', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: '', + variables: true, + }, + ], + + async run($) { + const taskListId = $.step.parameters.taskListId; + const title = $.step.parameters.title; + + const params = { + showCompleted: true, + showHidden: true, + }; + + const { data } = await $.http.get(`/tasks/v1/lists/${taskListId}/tasks`, { + params, + }); + + const filteredTask = data.items?.filter((task) => + task.title.includes(title) + ); + + $.setActionItem({ + raw: filteredTask[0], + }); + }, +}); diff --git a/packages/backend/src/apps/google-tasks/actions/index.js b/packages/backend/src/apps/google-tasks/actions/index.js new file mode 100644 index 0000000..ed71df7 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/actions/index.js @@ -0,0 +1,6 @@ +import createTask from './create-task/index.js'; +import createTaskList from './create-task-list/index.js'; +import findTask from './find-task/index.js'; +import updateTask from './update-task/index.js'; + +export default [createTask, createTaskList, findTask, updateTask]; diff --git a/packages/backend/src/apps/google-tasks/actions/update-task/index.js b/packages/backend/src/apps/google-tasks/actions/update-task/index.js new file mode 100644 index 0000000..e38aefa --- /dev/null +++ b/packages/backend/src/apps/google-tasks/actions/update-task/index.js @@ -0,0 +1,108 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Update task', + key: 'updateTask', + description: 'Updates an existing task.', + arguments: [ + { + label: 'Task List', + key: 'taskListId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTaskLists', + }, + ], + }, + }, + { + label: 'Task', + key: 'taskId', + type: 'dropdown', + required: true, + description: 'Ensure that you choose a list before proceeding.', + variables: true, + dependsOn: ['parameters.taskListId'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTasks', + }, + { + name: 'parameters.taskListId', + value: '{parameters.taskListId}', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: false, + description: 'Provide a new title for the revised task.', + variables: true, + }, + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: false, + description: + 'Specify the status of the updated task. If you opt for a custom value, enter either "needsAttention" or "completed."', + variables: true, + options: [ + { label: 'Incomplete', value: 'needsAction' }, + { label: 'Complete', value: 'completed' }, + ], + }, + { + label: 'Notes', + key: 'notes', + type: 'string', + required: false, + description: 'Provide a note for the revised task.', + variables: true, + }, + { + label: 'Due Date', + key: 'due', + type: 'string', + required: false, + description: + 'Specify the deadline for the task (as a RFC 3339 timestamp).', + variables: true, + }, + ], + + async run($) { + const { taskListId, taskId, title, status, notes, due } = $.step.parameters; + + const body = { + title, + status, + notes, + due, + }; + + const { data } = await $.http.patch( + `/tasks/v1/lists/${taskListId}/tasks/${taskId}`, + body + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/google-tasks/assets/favicon.svg b/packages/backend/src/apps/google-tasks/assets/favicon.svg new file mode 100644 index 0000000..1de5d7a --- /dev/null +++ b/packages/backend/src/apps/google-tasks/assets/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/packages/backend/src/apps/google-tasks/auth/generate-auth-url.js b/packages/backend/src/apps/google-tasks/auth/generate-auth-url.js new file mode 100644 index 0000000..c972ae1 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + prompt: 'select_account', + scope: authScope.join(' '), + response_type: 'code', + access_type: 'offline', + }); + + const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/google-tasks/auth/index.js b/packages/backend/src/apps/google-tasks/auth/index.js new file mode 100644 index 0000000..eefda57 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/google-tasks/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Google Cloud, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/google-tasks/auth/is-still-verified.js b/packages/backend/src/apps/google-tasks/auth/is-still-verified.js new file mode 100644 index 0000000..68f4d7d --- /dev/null +++ b/packages/backend/src/apps/google-tasks/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.resourceName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/google-tasks/auth/refresh-token.js b/packages/backend/src/apps/google-tasks/auth/refresh-token.js new file mode 100644 index 0000000..f706ffa --- /dev/null +++ b/packages/backend/src/apps/google-tasks/auth/refresh-token.js @@ -0,0 +1,25 @@ +import { URLSearchParams } from 'node:url'; +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://oauth2.googleapis.com/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/google-tasks/auth/verify-credentials.js b/packages/backend/src/apps/google-tasks/auth/verify-credentials.js new file mode 100644 index 0000000..a636b72 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/auth/verify-credentials.js @@ -0,0 +1,42 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + const { displayName } = currentUser.names.find( + (name) => name.metadata.primary + ); + const { value: email } = currentUser.emailAddresses.find( + (emailAddress) => emailAddress.metadata.primary + ); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + resourceName: currentUser.resourceName, + screenName: `${displayName} - ${email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/google-tasks/common/add-auth-header.js b/packages/backend/src/apps/google-tasks/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/google-tasks/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/google-tasks/common/auth-scope.js b/packages/backend/src/apps/google-tasks/common/auth-scope.js new file mode 100644 index 0000000..030adb8 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/common/auth-scope.js @@ -0,0 +1,7 @@ +const authScope = [ + 'https://www.googleapis.com/auth/tasks', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +export default authScope; diff --git a/packages/backend/src/apps/google-tasks/common/get-current-user.js b/packages/backend/src/apps/google-tasks/common/get-current-user.js new file mode 100644 index 0000000..2663ad2 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses' + ); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/google-tasks/dynamic-data/index.js b/packages/backend/src/apps/google-tasks/dynamic-data/index.js new file mode 100644 index 0000000..71940ff --- /dev/null +++ b/packages/backend/src/apps/google-tasks/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listTaskLists from './list-task-lists/index.js'; +import listTasks from './list-tasks/index.js'; + +export default [listTaskLists, listTasks]; diff --git a/packages/backend/src/apps/google-tasks/dynamic-data/list-task-lists/index.js b/packages/backend/src/apps/google-tasks/dynamic-data/list-task-lists/index.js new file mode 100644 index 0000000..a430a48 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/dynamic-data/list-task-lists/index.js @@ -0,0 +1,33 @@ +export default { + name: 'List task lists', + key: 'listTaskLists', + + async run($) { + const taskLists = { + data: [], + }; + + const params = { + maxResults: 100, + pageToken: undefined, + }; + + do { + const { data } = await $.http.get('/tasks/v1/users/@me/lists', { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.items) { + for (const taskList of data.items) { + taskLists.data.push({ + value: taskList.id, + name: taskList.title, + }); + } + } + } while (params.pageToken); + + return taskLists; + }, +}; diff --git a/packages/backend/src/apps/google-tasks/dynamic-data/list-tasks/index.js b/packages/backend/src/apps/google-tasks/dynamic-data/list-tasks/index.js new file mode 100644 index 0000000..534dbdb --- /dev/null +++ b/packages/backend/src/apps/google-tasks/dynamic-data/list-tasks/index.js @@ -0,0 +1,40 @@ +export default { + name: 'List tasks', + key: 'listTasks', + + async run($) { + const tasks = { + data: [], + }; + const taskListId = $.step.parameters.taskListId; + + const params = { + maxResults: 100, + pageToken: undefined, + }; + + if (!taskListId) { + return tasks; + } + + do { + const { data } = await $.http.get(`/tasks/v1/lists/${taskListId}/tasks`, { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.items) { + for (const task of data.items) { + if (task.title !== '') { + tasks.data.push({ + value: task.id, + name: task.title, + }); + } + } + } + } while (params.pageToken); + + return tasks; + }, +}; diff --git a/packages/backend/src/apps/google-tasks/index.js b/packages/backend/src/apps/google-tasks/index.js new file mode 100644 index 0000000..88b3edc --- /dev/null +++ b/packages/backend/src/apps/google-tasks/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Google Tasks', + key: 'google-tasks', + baseUrl: 'https://calendar.google.com/calendar/u/0/r/tasks', + apiBaseUrl: 'https://tasks.googleapis.com', + iconUrl: '{BASE_URL}/apps/google-tasks/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/google-tasks/connection', + primaryColor: '#0066DA', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, + triggers, +}); diff --git a/packages/backend/src/apps/google-tasks/triggers/index.js b/packages/backend/src/apps/google-tasks/triggers/index.js new file mode 100644 index 0000000..3677b97 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/triggers/index.js @@ -0,0 +1,5 @@ +import newCompletedTasks from './new-completed-tasks/index.js'; +import newTaskLists from './new-task-lists/index.js'; +import newTasks from './new-tasks/index.js'; + +export default [newCompletedTasks, newTaskLists, newTasks]; diff --git a/packages/backend/src/apps/google-tasks/triggers/new-completed-tasks/index.js b/packages/backend/src/apps/google-tasks/triggers/new-completed-tasks/index.js new file mode 100644 index 0000000..75979d8 --- /dev/null +++ b/packages/backend/src/apps/google-tasks/triggers/new-completed-tasks/index.js @@ -0,0 +1,59 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New completed tasks', + key: 'newCompletedTasks', + pollInterval: 15, + description: 'Triggers when a task is finished within a specified task list.', + arguments: [ + { + label: 'Task List', + key: 'taskListId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTaskLists', + }, + ], + }, + }, + ], + + async run($) { + const taskListId = $.step.parameters.taskListId; + + const params = { + maxResults: 100, + showCompleted: true, + showHidden: true, + pageToken: undefined, + }; + + do { + const { data } = await $.http.get(`/tasks/v1/lists/${taskListId}/tasks`, { + params, + }); + params.pageToken = data.nextPageToken; + + if (data.items?.length) { + for (const task of data.items) { + if (task.status === 'completed') { + $.pushTriggerItem({ + raw: task, + meta: { + internalId: task.id, + }, + }); + } + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/google-tasks/triggers/new-task-lists/index.js b/packages/backend/src/apps/google-tasks/triggers/new-task-lists/index.js new file mode 100644 index 0000000..29d156b --- /dev/null +++ b/packages/backend/src/apps/google-tasks/triggers/new-task-lists/index.js @@ -0,0 +1,31 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New task lists', + key: 'newTaskLists', + pollInterval: 15, + description: 'Triggers when a new task list is created.', + + async run($) { + const params = { + maxResults: 100, + pageToken: undefined, + }; + + do { + const { data } = await $.http.get('/tasks/v1/users/@me/lists'); + params.pageToken = data.nextPageToken; + + if (data.items?.length) { + for (const taskList of data.items.reverse()) { + $.pushTriggerItem({ + raw: taskList, + meta: { + internalId: taskList.id, + }, + }); + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/google-tasks/triggers/new-tasks/index.js b/packages/backend/src/apps/google-tasks/triggers/new-tasks/index.js new file mode 100644 index 0000000..497a21e --- /dev/null +++ b/packages/backend/src/apps/google-tasks/triggers/new-tasks/index.js @@ -0,0 +1,53 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New tasks', + key: 'newTasks', + pollInterval: 15, + description: 'Triggers when a new task is created.', + arguments: [ + { + label: 'Task List', + key: 'taskListId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTaskLists', + }, + ], + }, + }, + ], + + async run($) { + const taskListId = $.step.parameters.taskListId; + + const params = { + maxResults: 100, + pageToken: undefined, + }; + + do { + const { data } = await $.http.get(`/tasks/v1/lists/${taskListId}/tasks`); + params.pageToken = data.nextPageToken; + + if (data.items?.length) { + for (const task of data.items) { + $.pushTriggerItem({ + raw: task, + meta: { + internalId: task.id, + }, + }); + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/helix/actions/index.js b/packages/backend/src/apps/helix/actions/index.js new file mode 100644 index 0000000..8c1bb49 --- /dev/null +++ b/packages/backend/src/apps/helix/actions/index.js @@ -0,0 +1,3 @@ +import newChat from './new-chat/index.js'; + +export default [newChat]; diff --git a/packages/backend/src/apps/helix/actions/new-chat/index.js b/packages/backend/src/apps/helix/actions/new-chat/index.js new file mode 100644 index 0000000..c430fbb --- /dev/null +++ b/packages/backend/src/apps/helix/actions/new-chat/index.js @@ -0,0 +1,55 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'New chat', + key: 'newChat', + description: 'Create a new chat session for Helix AI.', + arguments: [ + { + label: 'Session ID', + key: 'sessionId', + type: 'string', + required: false, + description: + 'ID of the chat session to continue. Leave empty to start a new chat.', + variables: true, + }, + { + label: 'System Prompt', + key: 'systemPrompt', + type: 'string', + required: false, + description: + 'Optional system prompt to start the chat with. It will be used only for new chat sessions.', + variables: true, + }, + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + description: 'User input to start the chat with.', + variables: true, + }, + ], + + async run($) { + const response = await $.http.post('/api/v1/sessions/chat', { + session_id: $.step.parameters.sessionId, + system: $.step.parameters.systemPrompt, + messages: [ + { + role: 'user', + content: { + content_type: 'text', + parts: [$.step.parameters.input], + }, + }, + ], + }); + + $.setActionItem({ + raw: response.data, + }); + }, +}); diff --git a/packages/backend/src/apps/helix/assets/favicon.svg b/packages/backend/src/apps/helix/assets/favicon.svg new file mode 100644 index 0000000..e5e0b0b --- /dev/null +++ b/packages/backend/src/apps/helix/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/backend/src/apps/helix/auth/index.js b/packages/backend/src/apps/helix/auth/index.js new file mode 100644 index 0000000..3850ff2 --- /dev/null +++ b/packages/backend/src/apps/helix/auth/index.js @@ -0,0 +1,45 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'instanceUrl', + label: 'Helix instance URL', + type: 'string', + required: false, + readOnly: false, + value: 'https://app.tryhelix.ai', + placeholder: 'https://app.tryhelix.ai', + description: + 'Your Helix instance URL. Default is https://app.tryhelix.ai.', + clickToCopy: true, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Helix API Key of your account.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/helix/auth/is-still-verified.js b/packages/backend/src/apps/helix/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/helix/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/helix/auth/verify-credentials.js b/packages/backend/src/apps/helix/auth/verify-credentials.js new file mode 100644 index 0000000..c8f5345 --- /dev/null +++ b/packages/backend/src/apps/helix/auth/verify-credentials.js @@ -0,0 +1,9 @@ +const verifyCredentials = async ($) => { + await $.http.get('/api/v1/sessions'); + + await $.auth.set({ + screenName: $.auth.data.screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/helix/common/add-auth-header.js b/packages/backend/src/apps/helix/common/add-auth-header.js new file mode 100644 index 0000000..59ecf6b --- /dev/null +++ b/packages/backend/src/apps/helix/common/add-auth-header.js @@ -0,0 +1,10 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + const authorizationHeader = `Bearer ${$.auth.data.apiKey}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/helix/common/set-base-url.js b/packages/backend/src/apps/helix/common/set-base-url.js new file mode 100644 index 0000000..135149b --- /dev/null +++ b/packages/backend/src/apps/helix/common/set-base-url.js @@ -0,0 +1,11 @@ +const setBaseUrl = ($, requestConfig) => { + if ($.auth.data.instanceUrl) { + requestConfig.baseURL = $.auth.data.instanceUrl; + } else if ($.app.apiBaseUrl) { + requestConfig.baseURL = $.app.apiBaseUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/helix/index.js b/packages/backend/src/apps/helix/index.js new file mode 100644 index 0000000..ebdf6bc --- /dev/null +++ b/packages/backend/src/apps/helix/index.js @@ -0,0 +1,19 @@ +import defineApp from '../../helpers/define-app.js'; +import setBaseUrl from './common/set-base-url.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Helix', + key: 'helix', + baseUrl: 'https://tryhelix.ai', + apiBaseUrl: 'https://app.tryhelix.ai', + iconUrl: '{BASE_URL}/apps/helix/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/helix/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/http-request/actions/custom-request/index.js b/packages/backend/src/apps/http-request/actions/custom-request/index.js new file mode 100644 index 0000000..a533241 --- /dev/null +++ b/packages/backend/src/apps/http-request/actions/custom-request/index.js @@ -0,0 +1,157 @@ +import defineAction from '../../../../helpers/define-action.js'; + +function isPossiblyTextBased(contentType) { + if (!contentType) return false; + + return ( + contentType.startsWith('application/json') || + contentType.startsWith('text/') + ); +} + +function throwIfFileSizeExceedsLimit(contentLength) { + const maxFileSize = 25 * 1024 * 1024; // 25MB + + if (Number(contentLength) > maxFileSize) { + throw new Error( + `Response is too large. Maximum size is 25MB. Actual size is ${contentLength}` + ); + } +} + +export default defineAction({ + name: 'Custom request', + key: 'customRequest', + description: 'Makes a custom HTTP request by providing raw details.', + arguments: [ + { + label: 'Method', + key: 'method', + type: 'dropdown', + required: true, + description: `The HTTP method we'll use to perform the request.`, + value: 'GET', + options: [ + { label: 'DELETE', value: 'DELETE' }, + { label: 'GET', value: 'GET' }, + { label: 'PATCH', value: 'PATCH' }, + { label: 'POST', value: 'POST' }, + { label: 'PUT', value: 'PUT' }, + ], + }, + { + label: 'URL', + key: 'url', + type: 'string', + required: true, + description: 'Any URL with a querystring will be re-encoded properly.', + variables: true, + }, + { + label: 'Data', + key: 'data', + type: 'string', + required: false, + description: 'Place raw JSON data here.', + variables: true, + }, + { + label: 'Headers', + key: 'headers', + type: 'dynamic', + required: false, + description: 'Add or remove headers as needed', + value: [ + { + key: 'Content-Type', + value: 'application/json', + }, + ], + fields: [ + { + label: 'Key', + key: 'key', + type: 'string', + required: true, + description: 'Header key', + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + description: 'Header value', + variables: true, + }, + ], + }, + ], + + async run($) { + const method = $.step.parameters.method; + const data = $.step.parameters.data || null; + const url = $.step.parameters.url; + const headers = $.step.parameters.headers; + + const headersObject = headers.reduce((result, entry) => { + const key = entry.key?.toLowerCase(); + const value = entry.value; + + if (key && value) { + return { + ...result, + [entry.key?.toLowerCase()]: entry.value, + }; + } + + return result; + }, {}); + + let expectedResponseContentType = headersObject.accept; + + // in case HEAD request is not supported by the URL + try { + const metadataResponse = await $.http.head(url, { + headers: headersObject, + }); + + if (!expectedResponseContentType) { + expectedResponseContentType = metadataResponse.headers['content-type']; + } + + throwIfFileSizeExceedsLimit(metadataResponse.headers['content-length']); + // eslint-disable-next-line no-empty + } catch {} + + const requestData = { + url, + method, + data, + headers: headersObject, + }; + + if (!isPossiblyTextBased(expectedResponseContentType)) { + requestData.responseType = 'arraybuffer'; + } + + const response = await $.http.request(requestData); + + throwIfFileSizeExceedsLimit(response.headers['content-length']); + + let responseData = response.data; + + if (!isPossiblyTextBased(expectedResponseContentType)) { + responseData = Buffer.from(responseData).toString('base64'); + } + + $.setActionItem({ + raw: { + data: responseData, + headers: response.headers, + status: response.status, + statusText: response.statusText + } + }); + }, +}); diff --git a/packages/backend/src/apps/http-request/actions/index.js b/packages/backend/src/apps/http-request/actions/index.js new file mode 100644 index 0000000..0396f24 --- /dev/null +++ b/packages/backend/src/apps/http-request/actions/index.js @@ -0,0 +1,3 @@ +import customRequest from './custom-request/index.js'; + +export default [customRequest]; diff --git a/packages/backend/src/apps/http-request/assets/favicon.svg b/packages/backend/src/apps/http-request/assets/favicon.svg new file mode 100644 index 0000000..87c7dae --- /dev/null +++ b/packages/backend/src/apps/http-request/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/http-request/index.js b/packages/backend/src/apps/http-request/index.js new file mode 100644 index 0000000..b53c1fc --- /dev/null +++ b/packages/backend/src/apps/http-request/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'HTTP Request', + key: 'http-request', + iconUrl: '{BASE_URL}/apps/http-request/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/http-request/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#000000', + actions, +}); diff --git a/packages/backend/src/apps/hubspot/actions/create-contact/index.js b/packages/backend/src/apps/hubspot/actions/create-contact/index.js new file mode 100644 index 0000000..790854a --- /dev/null +++ b/packages/backend/src/apps/hubspot/actions/create-contact/index.js @@ -0,0 +1,83 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create contact', + key: 'createContact', + description: `Create contact on user's account.`, + arguments: [ + { + label: 'Company name', + key: 'company', + type: 'string', + required: false, + variables: true, + }, + { + label: 'Email', + key: 'email', + type: 'string', + required: false, + variables: true, + }, + { + label: 'First name', + key: 'firstName', + type: 'string', + required: false, + variables: true, + }, + { + label: 'Last name', + key: 'lastName', + type: 'string', + required: false, + description: 'Last name', + variables: true, + }, + { + label: 'Phone', + key: 'phone', + type: 'string', + required: false, + variables: true, + }, + { + label: 'Website URL', + key: 'website', + type: 'string', + required: false, + variables: true, + }, + { + label: 'Owner ID', + key: 'hubspotOwnerId', + type: 'string', + required: false, + variables: true, + }, + ], + + async run($) { + const company = $.step.parameters.company; + const email = $.step.parameters.email; + const firstName = $.step.parameters.firstName; + const lastName = $.step.parameters.lastName; + const phone = $.step.parameters.phone; + const website = $.step.parameters.website; + const hubspotOwnerId = $.step.parameters.hubspotOwnerId; + + const response = await $.http.post(`crm/v3/objects/contacts`, { + properties: { + company, + email, + firstname: firstName, + lastname: lastName, + phone, + website, + hubspot_owner_id: hubspotOwnerId, + }, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/hubspot/actions/index.js b/packages/backend/src/apps/hubspot/actions/index.js new file mode 100644 index 0000000..9dbfbd0 --- /dev/null +++ b/packages/backend/src/apps/hubspot/actions/index.js @@ -0,0 +1,3 @@ +import createContact from './create-contact/index.js'; + +export default [createContact]; diff --git a/packages/backend/src/apps/hubspot/assets/favicon.svg b/packages/backend/src/apps/hubspot/assets/favicon.svg new file mode 100644 index 0000000..c21891f --- /dev/null +++ b/packages/backend/src/apps/hubspot/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/backend/src/apps/hubspot/auth/generate-auth-url.js b/packages/backend/src/apps/hubspot/auth/generate-auth-url.js new file mode 100644 index 0000000..3b7bf2e --- /dev/null +++ b/packages/backend/src/apps/hubspot/auth/generate-auth-url.js @@ -0,0 +1,19 @@ +import { URLSearchParams } from 'url'; +import scopes from '../common/scopes.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const callbackUrl = oauthRedirectUrlField.value; + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: callbackUrl, + scope: scopes.join(' '), + }); + + const url = `https://app.hubspot.com/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ url }); +} diff --git a/packages/backend/src/apps/hubspot/auth/index.js b/packages/backend/src/apps/hubspot/auth/index.js new file mode 100644 index 0000000..47f7563 --- /dev/null +++ b/packages/backend/src/apps/hubspot/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/hubspot/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in HubSpot OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/hubspot/auth/is-still-verified.js b/packages/backend/src/apps/hubspot/auth/is-still-verified.js new file mode 100644 index 0000000..7927d27 --- /dev/null +++ b/packages/backend/src/apps/hubspot/auth/is-still-verified.js @@ -0,0 +1,9 @@ +import getAccessTokenInfo from '../common/get-access-token-info.js'; + +const isStillVerified = async ($) => { + await getAccessTokenInfo($); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/hubspot/auth/refresh-token.js b/packages/backend/src/apps/hubspot/auth/refresh-token.js new file mode 100644 index 0000000..9afea4e --- /dev/null +++ b/packages/backend/src/apps/hubspot/auth/refresh-token.js @@ -0,0 +1,27 @@ +import { URLSearchParams } from 'url'; + +const refreshToken = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + redirect_uri: callbackUrl, + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post('/oauth/v1/token', params.toString()); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/hubspot/auth/verify-credentials.js b/packages/backend/src/apps/hubspot/auth/verify-credentials.js new file mode 100644 index 0000000..15e44b4 --- /dev/null +++ b/packages/backend/src/apps/hubspot/auth/verify-credentials.js @@ -0,0 +1,51 @@ +import { URLSearchParams } from 'url'; +import getAccessTokenInfo from '../common/get-access-token-info.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const callbackUrl = oauthRedirectUrlField.value; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + redirect_uri: callbackUrl, + code: $.auth.data.code, + }); + + const { data: verifiedCredentials } = await $.http.post( + '/oauth/v1/token', + params.toString() + ); + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + } = verifiedCredentials; + + await $.auth.set({ + accessToken, + refreshToken, + expiresIn, + }); + + const accessTokenInfo = await getAccessTokenInfo($); + + await $.auth.set({ + screenName: accessTokenInfo.user, + hubDomain: accessTokenInfo.hub_domain, + scopes: accessTokenInfo.scopes, + scopeToScopeGroupPks: accessTokenInfo.scope_to_scope_group_pks, + trialScopes: accessTokenInfo.trial_scopes, + trialScopeToScoreGroupPks: accessTokenInfo.trial_scope_to_scope_group_pks, + hubId: accessTokenInfo.hub_id, + appId: accessTokenInfo.app_id, + userId: accessTokenInfo.user_id, + expiresIn: accessTokenInfo.expires_in, + tokenType: accessTokenInfo.token_type, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/hubspot/common/add-auth-header.js b/packages/backend/src/apps/hubspot/common/add-auth-header.js new file mode 100644 index 0000000..38e6909 --- /dev/null +++ b/packages/backend/src/apps/hubspot/common/add-auth-header.js @@ -0,0 +1,13 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.additionalProperties?.skipAddingAuthHeader) + return requestConfig; + + if ($.auth.data?.accessToken) { + const authorizationHeader = `Bearer ${$.auth.data.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/hubspot/common/get-access-token-info.js b/packages/backend/src/apps/hubspot/common/get-access-token-info.js new file mode 100644 index 0000000..fec4f64 --- /dev/null +++ b/packages/backend/src/apps/hubspot/common/get-access-token-info.js @@ -0,0 +1,9 @@ +const getAccessTokenInfo = async ($) => { + const response = await $.http.get( + `/oauth/v1/access-tokens/${$.auth.data.accessToken}` + ); + + return response.data; +}; + +export default getAccessTokenInfo; diff --git a/packages/backend/src/apps/hubspot/common/scopes.js b/packages/backend/src/apps/hubspot/common/scopes.js new file mode 100644 index 0000000..38cb30a --- /dev/null +++ b/packages/backend/src/apps/hubspot/common/scopes.js @@ -0,0 +1,3 @@ +const scopes = ['crm.objects.contacts.read', 'crm.objects.contacts.write']; + +export default scopes; diff --git a/packages/backend/src/apps/hubspot/index.js b/packages/backend/src/apps/hubspot/index.js new file mode 100644 index 0000000..f7cae7e --- /dev/null +++ b/packages/backend/src/apps/hubspot/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import actions from './actions/index.js'; +import auth from './auth/index.js'; + +export default defineApp({ + name: 'HubSpot', + key: 'hubspot', + iconUrl: '{BASE_URL}/apps/hubspot/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/hubspot/connection', + supportsConnections: true, + baseUrl: 'https://www.hubspot.com', + apiBaseUrl: 'https://api.hubapi.com', + primaryColor: '#F95C35', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-client/fields.js b/packages/backend/src/apps/invoice-ninja/actions/create-client/fields.js new file mode 100644 index 0000000..419e45b --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-client/fields.js @@ -0,0 +1,639 @@ +export const fields = [ + { + label: 'Client Name', + key: 'clientName', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Contact First Name', + key: 'contactFirstName', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Contact Last Name', + key: 'contactLastName', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Contact Email', + key: 'contactEmail', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Contact Phone', + key: 'contactPhone', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Language Code', + key: 'languageCode', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { value: 1, label: 'English - United States' }, + { value: 2, label: 'Italian' }, + { value: 3, label: 'German' }, + { value: 4, label: 'French' }, + { value: 5, label: 'Portuguese - Brazilian' }, + { value: 6, label: 'Dutch' }, + { value: 7, label: 'Spanish' }, + { value: 8, label: 'Norwegian' }, + { value: 9, label: 'Danish' }, + { value: 10, label: 'Japanese' }, + { value: 11, label: 'Swedish' }, + { value: 12, label: 'Spanish - Spain' }, + { value: 13, label: 'French - Canada' }, + { value: 14, label: 'Lithuanian' }, + { value: 15, label: 'Polish' }, + { value: 16, label: 'Czech' }, + { value: 17, label: 'Croatian' }, + { value: 18, label: 'Albanian' }, + { value: 19, label: 'Greek' }, + { value: 20, label: 'English - United Kingdom' }, + { value: 21, label: 'Portuguese - Portugal' }, + { value: 22, label: 'Slovenian' }, + { value: 23, label: 'Finnish' }, + { value: 24, label: 'Romanian' }, + { value: 25, label: 'Turkish - Turkey' }, + { value: 26, label: 'Thai' }, + { value: 27, label: 'Macedonian' }, + { value: 28, label: 'Chinese - Taiwan' }, + { value: 29, label: 'Russian (Russia)' }, + { value: 30, label: 'Arabic' }, + { value: 31, label: 'Persian' }, + { value: 32, label: 'Latvian' }, + { value: 33, label: 'Serbian' }, + { value: 34, label: 'Slovak' }, + { value: 35, label: 'Estonian' }, + { value: 36, label: 'Bulgarian' }, + { value: 37, label: 'Hebrew' }, + { value: 38, label: 'Khmer' }, + { value: 39, label: 'Hungarian' }, + { value: 40, label: 'French - Swiss' }, + ], + }, + { + label: 'Currency Code', + key: 'currencyCode', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { value: 1, label: 'US Dollar' }, + { value: 2, label: 'British Pound' }, + { value: 3, label: 'Euro' }, + { value: 4, label: 'South African Rand' }, + { value: 5, label: 'Danish Krone' }, + { value: 6, label: 'Israeli Shekel' }, + { value: 7, label: 'Swedish Krona' }, + { value: 8, label: 'Kenyan Shilling' }, + { value: 9, label: 'Canadian Dollar' }, + { value: 10, label: 'Philippine Peso' }, + { value: 11, label: 'Indian Rupee' }, + { value: 12, label: 'Australian Dollar' }, + { value: 13, label: 'Singapore Dollar' }, + { value: 14, label: 'Norske Kroner' }, + { value: 15, label: 'New Zealand Dollar' }, + { value: 16, label: 'Vietnamese Dong' }, + { value: 17, label: 'Swiss Franc' }, + { value: 18, label: 'Guatemalan Quetzal' }, + { value: 19, label: 'Malaysian Ringgit' }, + { value: 20, label: 'Brazilian Real' }, + { value: 21, label: 'Thai Baht' }, + { value: 22, label: 'Nigerian Naira' }, + { value: 23, label: 'Argentine Peso' }, + { value: 24, label: 'Bangladeshi Taka' }, + { value: 25, label: 'United Arab Emirates Dirham' }, + { value: 26, label: 'Hong Kong Dollar' }, + { value: 27, label: 'Indonesian Rupiah' }, + { value: 28, label: 'Mexican Peso' }, + { value: 29, label: 'Egyptian Pound' }, + { value: 30, label: 'Colombian Peso' }, + { value: 31, label: 'West African Franc' }, + { value: 32, label: 'Chinese Renminbi' }, + { value: 33, label: 'Rwandan Franc' }, + { value: 34, label: 'Tanzanian Shilling' }, + { value: 35, label: 'Netherlands Antillean Guilder' }, + { value: 36, label: 'Trinidad and Tobago Dollar' }, + { value: 37, label: 'East Caribbean Dollar' }, + { value: 38, label: 'Ghanaian Cedi' }, + { value: 39, label: 'Bulgarian Lev' }, + { value: 40, label: 'Aruban Florin' }, + { value: 41, label: 'Turkish Lira' }, + { value: 42, label: 'Romanian New Leu' }, + { value: 43, label: 'Croatian Kuna' }, + { value: 44, label: 'Saudi Riyal' }, + { value: 45, label: 'Japanese Yen' }, + { value: 46, label: 'Maldivian Rufiyaa' }, + { value: 47, label: 'Costa Rican Colón' }, + { value: 48, label: 'Pakistani Rupee' }, + { value: 49, label: 'Polish Zloty' }, + { value: 50, label: 'Sri Lankan Rupee' }, + { value: 51, label: 'Czech Koruna' }, + { value: 52, label: 'Uruguayan Peso' }, + { value: 53, label: 'Namibian Dollar' }, + { value: 54, label: 'Tunisian Dinar' }, + { value: 55, label: 'Russian Ruble' }, + { value: 56, label: 'Mozambican Metical' }, + { value: 57, label: 'Omani Rial' }, + { value: 58, label: 'Ukrainian Hryvnia' }, + { value: 59, label: 'Macanese Pataca' }, + { value: 60, label: 'Taiwan New Dollar' }, + { value: 61, label: 'Dominican Peso' }, + { value: 62, label: 'Chilean Peso' }, + { value: 63, label: 'Icelandic Króna' }, + { value: 64, label: 'Papua New Guinean Kina' }, + { value: 65, label: 'Jordanian Dinar' }, + { value: 66, label: 'Myanmar Kyat' }, + { value: 67, label: 'Peruvian Sol' }, + { value: 68, label: 'Botswana Pula' }, + { value: 69, label: 'Hungarian Forint' }, + { value: 70, label: 'Ugandan Shilling' }, + { value: 71, label: 'Barbadian Dollar' }, + { value: 72, label: 'Brunei Dollar' }, + { value: 73, label: 'Georgian Lari' }, + { value: 74, label: 'Qatari Riyal' }, + { value: 75, label: 'Honduran Lempira' }, + { value: 76, label: 'Surinamese Dollar' }, + { value: 77, label: 'Bahraini Dinar' }, + { value: 78, label: 'Venezuelan Bolivars' }, + { value: 79, label: 'South Korean Won' }, + { value: 80, label: 'Moroccan Dirham' }, + { value: 81, label: 'Jamaican Dollar' }, + { value: 82, label: 'Angolan Kwanza' }, + { value: 83, label: 'Haitian Gourde' }, + { value: 84, label: 'Zambian Kwacha' }, + { value: 85, label: 'Nepalese Rupee' }, + { value: 86, label: 'CFP Franc' }, + { value: 87, label: 'Mauritian Rupee' }, + { value: 88, label: 'Cape Verdean Escudo' }, + { value: 89, label: 'Kuwaiti Dinar' }, + { value: 90, label: 'Algerian Dinar' }, + { value: 91, label: 'Macedonian Denar' }, + { value: 92, label: 'Fijian Dollar' }, + { value: 93, label: 'Bolivian Boliviano' }, + { value: 94, label: 'Albanian Lek' }, + { value: 95, label: 'Serbian Dinar' }, + { value: 96, label: 'Lebanese Pound' }, + { value: 97, label: 'Armenian Dram' }, + { value: 98, label: 'Azerbaijan Manat' }, + { value: 99, label: 'Bosnia and Herzegovina Convertible Mark' }, + { value: 100, label: 'Belarusian Ruble' }, + { value: 101, label: 'Gibraltar Pound' }, + { value: 102, label: 'Moldovan Leu' }, + { value: 103, label: 'Kazakhstani Tenge' }, + { value: 104, label: 'Ethiopian Birr' }, + { value: 105, label: 'Gambia Dalasi' }, + { value: 106, label: 'Paraguayan Guarani' }, + { value: 107, label: 'Malawi Kwacha' }, + { value: 108, label: 'Zimbabwean Dollar' }, + { value: 109, label: 'Cambodian Riel' }, + { value: 110, label: 'Vanuatu Vatu' }, + { value: 111, label: 'Cuban Peso' }, + { value: 112, label: 'Cayman Island Dollar' }, + { value: 113, label: 'Swazi lilangeni' }, + { value: 114, label: 'BZ Dollar' }, + { value: 115, label: 'Libyan Dinar' }, + { value: 116, label: 'Silver Troy Ounce' }, + { value: 117, label: 'Gold Troy Ounce' }, + { value: 118, label: 'Nicaraguan Córdoba' }, + ], + }, + { + label: 'Id Number', + key: 'idNumber', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Vat Number', + key: 'vatNumber', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Street Address', + key: 'streetAddress', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Apt/Suite', + key: 'aptSuite', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'City', + key: 'city', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'State/Province', + key: 'stateProvince', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Postal Code', + key: 'postalCode', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Country Code', + key: 'countryCode', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { value: 4, label: 'Afghanistan' }, + { value: 8, label: 'Albania' }, + { value: 12, label: 'Algeria' }, + { value: 16, label: 'American Samoa' }, + { value: 20, label: 'Andorra' }, + { value: 24, label: 'Angola' }, + { value: 660, label: 'Anguilla' }, + { value: 10, label: 'Antarctica' }, + { value: 28, label: 'Antigua and Barbuda' }, + { value: 32, label: 'Argentina' }, + { value: 51, label: 'Armenia' }, + { value: 533, label: 'Aruba' }, + { value: 36, label: 'Australia' }, + { value: 40, label: 'Austria' }, + { value: 31, label: 'Azerbaijan' }, + { value: 44, label: 'Bahamas' }, + { value: 48, label: 'Bahrain' }, + { value: 50, label: 'Bangladesh' }, + { value: 52, label: 'Barbados' }, + { value: 112, label: 'Belarus' }, + { value: 56, label: 'Belgium' }, + { value: 84, label: 'Belize' }, + { value: 204, label: 'Benin' }, + { value: 60, label: 'Bermuda' }, + { value: 64, label: 'Bhutan' }, + { value: 68, label: 'Bolivia, Plurinational State of' }, + { value: 535, label: 'Bonaire, Sint Eustatius and Saba' }, + { value: 70, label: 'Bosnia and Herzegovina' }, + { value: 72, label: 'Botswana' }, + { value: 74, label: 'Bouvet Island' }, + { value: 76, label: 'Brazil' }, + { value: 86, label: 'British Indian Ocean Territory' }, + { value: 96, label: 'Brunei Darussalam' }, + { value: 100, label: 'Bulgaria' }, + { value: 854, label: 'Burkina Faso' }, + { value: 108, label: 'Burundi' }, + { value: 116, label: 'Cambodia' }, + { value: 120, label: 'Cameroon' }, + { value: 124, label: 'Canada' }, + { value: 132, label: 'Cape Verde' }, + { value: 136, label: 'Cayman Islands' }, + { value: 140, label: 'Central African Republic' }, + { value: 148, label: 'Chad' }, + { value: 152, label: 'Chile' }, + { value: 156, label: 'China' }, + { value: 162, label: 'Christmas Island' }, + { value: 166, label: 'Cocos (Keeling) Islands' }, + { value: 170, label: 'Colombia' }, + { value: 174, label: 'Comoros' }, + { value: 178, label: 'Congo' }, + { value: 180, label: 'Congo, the Democratic Republic of the' }, + { value: 184, label: 'Cook Islands' }, + { value: 188, label: 'Costa Rica' }, + { value: 191, label: 'Croatia' }, + { value: 192, label: 'Cuba' }, + { value: 531, label: 'Curaçao' }, + { value: 196, label: 'Cyprus' }, + { value: 203, label: 'Czech Republic' }, + { value: 384, label: "Côte d'Ivoire" }, + { value: 208, label: 'Denmark' }, + { value: 262, label: 'Djibouti' }, + { value: 212, label: 'Dominica' }, + { value: 214, label: 'Dominican Republic' }, + { value: 218, label: 'Ecuador' }, + { value: 818, label: 'Egypt' }, + { value: 222, label: 'El Salvador' }, + { value: 226, label: 'Equatorial Guinea' }, + { value: 232, label: 'Eritrea' }, + { value: 233, label: 'Estonia' }, + { value: 231, label: 'Ethiopia' }, + { value: 238, label: 'Falkland Islands (Malvinas)' }, + { value: 234, label: 'Faroe Islands' }, + { value: 242, label: 'Fiji' }, + { value: 246, label: 'Finland' }, + { value: 250, label: 'France' }, + { value: 254, label: 'French Guiana' }, + { value: 258, label: 'French Polynesia' }, + { value: 260, label: 'French Southern Territories' }, + { value: 266, label: 'Gabon' }, + { value: 270, label: 'Gambia' }, + { value: 268, label: 'Georgia' }, + { value: 276, label: 'Germany' }, + { value: 288, label: 'Ghana' }, + { value: 292, label: 'Gibraltar' }, + { value: 300, label: 'Greece' }, + { value: 304, label: 'Greenland' }, + { value: 308, label: 'Grenada' }, + { value: 312, label: 'Guadeloupe' }, + { value: 316, label: 'Guam' }, + { value: 320, label: 'Guatemala' }, + { value: 831, label: 'Guernsey' }, + { value: 324, label: 'Guinea' }, + { value: 624, label: 'Guinea-Bissau' }, + { value: 328, label: 'Guyana' }, + { value: 332, label: 'Haiti' }, + { value: 334, label: 'Heard Island and McDonald Islands' }, + { value: 336, label: 'Holy See (Vatican City State)' }, + { value: 340, label: 'Honduras' }, + { value: 344, label: 'Hong Kong' }, + { value: 348, label: 'Hungary' }, + { value: 352, label: 'Iceland' }, + { value: 356, label: 'India' }, + { value: 360, label: 'Indonesia' }, + { value: 364, label: 'Iran, Islamic Republic of' }, + { value: 368, label: 'Iraq' }, + { value: 372, label: 'Ireland' }, + { value: 833, label: 'Isle of Man' }, + { value: 376, label: 'Israel' }, + { value: 380, label: 'Italy' }, + { value: 388, label: 'Jamaica' }, + { value: 392, label: 'Japan' }, + { value: 832, label: 'Jersey' }, + { value: 400, label: 'Jordan' }, + { value: 398, label: 'Kazakhstan' }, + { value: 404, label: 'Kenya' }, + { value: 296, label: 'Kiribati' }, + { value: 408, label: "Korea, Democratic People's Republic of" }, + { value: 410, label: 'Korea, Republic of' }, + { value: 414, label: 'Kuwait' }, + { value: 417, label: 'Kyrgyzstan' }, + { value: 418, label: "Lao People's Democratic Republic" }, + { value: 428, label: 'Latvia' }, + { value: 422, label: 'Lebanon' }, + { value: 426, label: 'Lesotho' }, + { value: 430, label: 'Liberia' }, + { value: 434, label: 'Libya' }, + { value: 438, label: 'Liechtenstein' }, + { value: 440, label: 'Lithuania' }, + { value: 442, label: 'Luxembourg' }, + { value: 446, label: 'Macao' }, + { value: 807, label: 'Macedonia, the former Yugoslav Republic of' }, + { value: 450, label: 'Madagascar' }, + { value: 454, label: 'Malawi' }, + { value: 458, label: 'Malaysia' }, + { value: 462, label: 'Maldives' }, + { value: 466, label: 'Mali' }, + { value: 470, label: 'Malta' }, + { value: 584, label: 'Marshall Islands' }, + { value: 474, label: 'Martinique' }, + { value: 478, label: 'Mauritania' }, + { value: 480, label: 'Mauritius' }, + { value: 175, label: 'Mayotte' }, + { value: 484, label: 'Mexico' }, + { value: 583, label: 'Micronesia, Federated States of' }, + { value: 498, label: 'Moldova, Republic of' }, + { value: 492, label: 'Monaco' }, + { value: 496, label: 'Mongolia' }, + { value: 499, label: 'Montenegro' }, + { value: 500, label: 'Montserrat' }, + { value: 504, label: 'Morocco' }, + { value: 508, label: 'Mozambique' }, + { value: 104, label: 'Myanmar' }, + { value: 516, label: 'Namibia' }, + { value: 520, label: 'Nauru' }, + { value: 524, label: 'Nepal' }, + { value: 528, label: 'Netherlands' }, + { value: 540, label: 'New Caledonia' }, + { value: 554, label: 'New Zealand' }, + { value: 558, label: 'Nicaragua' }, + { value: 562, label: 'Niger' }, + { value: 566, label: 'Nigeria' }, + { value: 570, label: 'Niue' }, + { value: 574, label: 'Norfolk Island' }, + { value: 580, label: 'Northern Mariana Islands' }, + { value: 578, label: 'Norway' }, + { value: 512, label: 'Oman' }, + { value: 586, label: 'Pakistan' }, + { value: 585, label: 'Palau' }, + { value: 275, label: 'Palestine' }, + { value: 591, label: 'Panama' }, + { value: 598, label: 'Papua New Guinea' }, + { value: 600, label: 'Paraguay' }, + { value: 604, label: 'Peru' }, + { value: 608, label: 'Philippines' }, + { value: 612, label: 'Pitcairn' }, + { value: 616, label: 'Poland' }, + { value: 620, label: 'Portugal' }, + { value: 630, label: 'Puerto Rico' }, + { value: 634, label: 'Qatar' }, + { value: 642, label: 'Romania' }, + { value: 643, label: 'Russian Federation' }, + { value: 646, label: 'Rwanda' }, + { value: 638, label: 'Réunion' }, + { value: 652, label: 'Saint Barthélemy' }, + { value: 654, label: 'Saint Helena, Ascension and Tristan da Cunha' }, + { value: 659, label: 'Saint Kitts and Nevis' }, + { value: 662, label: 'Saint Lucia' }, + { value: 663, label: 'Saint Martin (French part)' }, + { value: 666, label: 'Saint Pierre and Miquelon' }, + { value: 670, label: 'Saint Vincent and the Grenadines' }, + { value: 882, label: 'Samoa' }, + { value: 674, label: 'San Marino' }, + { value: 678, label: 'Sao Tome and Principe' }, + { value: 682, label: 'Saudi Arabia' }, + { value: 686, label: 'Senegal' }, + { value: 688, label: 'Serbia' }, + { value: 690, label: 'Seychelles' }, + { value: 694, label: 'Sierra Leone' }, + { value: 702, label: 'Singapore' }, + { value: 534, label: 'Sint Maarten (Dutch part)' }, + { value: 703, label: 'Slovakia' }, + { value: 705, label: 'Slovenia' }, + { value: 90, label: 'Solomon Islands' }, + { value: 706, label: 'Somalia' }, + { value: 710, label: 'South Africa' }, + { value: 239, label: 'South Georgia and the South Sandwich Islands' }, + { value: 728, label: 'South Sudan' }, + { value: 724, label: 'Spain' }, + { value: 144, label: 'Sri Lanka' }, + { value: 729, label: 'Sudan' }, + { value: 740, label: 'Suriname' }, + { value: 744, label: 'Svalbard and Jan Mayen' }, + { value: 748, label: 'Swaziland' }, + { value: 752, label: 'Sweden' }, + { value: 756, label: 'Switzerland' }, + { value: 760, label: 'Syrian Arab Republic' }, + { value: 158, label: 'Taiwan, Province of China' }, + { value: 762, label: 'Tajikistan' }, + { value: 834, label: 'Tanzania, United Republic of' }, + { value: 764, label: 'Thailand' }, + { value: 626, label: 'Timor-Leste' }, + { value: 768, label: 'Togo' }, + { value: 772, label: 'Tokelau' }, + { value: 776, label: 'Tonga' }, + { value: 780, label: 'Trinidad and Tobago' }, + { value: 788, label: 'Tunisia' }, + { value: 792, label: 'Turkey' }, + { value: 795, label: 'Turkmenistan' }, + { value: 796, label: 'Turks and Caicos Islands' }, + { value: 798, label: 'Tuvalu' }, + { value: 800, label: 'Uganda' }, + { value: 804, label: 'Ukraine' }, + { value: 784, label: 'United Arab Emirates' }, + { value: 826, label: 'United Kingdom' }, + { value: 840, label: 'United States' }, + { value: 581, label: 'United States Minor Outlying Islands' }, + { value: 858, label: 'Uruguay' }, + { value: 860, label: 'Uzbekistan' }, + { value: 548, label: 'Vanuatu' }, + { value: 862, label: 'Venezuela, Bolivarian Republic of' }, + { value: 704, label: 'Viet Nam' }, + { value: 92, label: 'Virgin Islands, British' }, + { value: 850, label: 'Virgin Islands, U.S.' }, + { value: 876, label: 'Wallis and Futuna' }, + { value: 732, label: 'Western Sahara' }, + { value: 887, label: 'Yemen' }, + { value: 894, label: 'Zambia' }, + { value: 716, label: 'Zimbabwe' }, + { value: 248, label: 'Åland Islands' }, + ], + }, + { + label: 'Shipping Street Address', + key: 'shippingStreetAddress', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Shipping Apt/Suite', + key: 'shippingAptSuite', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Shipping City', + key: 'shippingCity', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Shipping State/Province', + key: 'shippingStateProvince', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Shipping Postal Code', + key: 'shippingPostalCode', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Shipping Country Code', + key: 'shippingCountryCode', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Private Notes', + key: 'privateNotes', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Public Notes', + key: 'publicNotes', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Website', + key: 'website', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 1', + key: 'customValue1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 2', + key: 'customValue2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 3', + key: 'customValue3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 4', + key: 'customValue4', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-client/index.js b/packages/backend/src/apps/invoice-ninja/actions/create-client/index.js new file mode 100644 index 0000000..47edb57 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-client/index.js @@ -0,0 +1,84 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create client', + key: 'createClient', + description: 'Creates a new client.', + arguments: fields, + + async run($) { + const { + clientName, + contactFirstName, + contactLastName, + contactEmail, + contactPhone, + languageCode, + currencyCode, + idNumber, + vatNumber, + streetAddress, + aptSuite, + city, + stateProvince, + postalCode, + countryCode, + shippingStreetAddress, + shippingAptSuite, + shippingCity, + shippingStateProvince, + shippingPostalCode, + shippingCountryCode, + privateNotes, + publicNotes, + website, + customValue1, + customValue2, + customValue3, + customValue4, + } = $.step.parameters; + + const bodyFields = { + name: clientName, + contacts: { + first_name: contactFirstName, + last_name: contactLastName, + email: contactEmail, + phone: contactPhone, + }, + settings: { + language_id: languageCode, + currency_id: currencyCode, + }, + id_number: idNumber, + vat_number: vatNumber, + address1: streetAddress, + address2: aptSuite, + city: city, + state: stateProvince, + postal_code: postalCode, + country_id: countryCode, + shipping_address1: shippingStreetAddress, + shipping_address2: shippingAptSuite, + shipping_city: shippingCity, + shipping_state: shippingStateProvince, + shipping_postal_code: shippingPostalCode, + shipping_country_id: shippingCountryCode, + private_notes: privateNotes, + public_notes: publicNotes, + website: website, + custom_value1: customValue1, + custom_value2: customValue2, + custom_value3: customValue3, + custom_value4: customValue4, + }; + + const body = filterProvidedFields(bodyFields); + + const response = await $.http.post('/v1/clients', body); + + $.setActionItem({ raw: response.data.data }); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-invoice/fields.js b/packages/backend/src/apps/invoice-ninja/actions/create-invoice/fields.js new file mode 100644 index 0000000..f2556ea --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-invoice/fields.js @@ -0,0 +1,407 @@ +export const fields = [ + { + label: 'Client ID', + key: 'clientId', + type: 'dropdown', + required: true, + description: 'The ID of the client, not the name or email address.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listClients', + }, + ], + }, + }, + { + label: 'Send Email', + key: 'sendEmail', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'False', value: 'false' }, + { label: 'True', value: 'true' }, + ], + }, + { + label: 'Mark Sent', + key: 'markSent', + type: 'dropdown', + required: false, + description: 'Setting this to true creates the invoice as sent.', + variables: true, + options: [ + { label: 'False', value: 'false' }, + { label: 'True', value: 'true' }, + ], + }, + { + label: 'Paid', + key: 'paid', + type: 'dropdown', + required: false, + description: 'Setting this to true creates the invoice as paid.', + variables: true, + options: [ + { label: 'False', value: 'false' }, + { label: 'True', value: 'true' }, + ], + }, + { + label: 'Amount Paid', + key: 'amountPaid', + type: 'string', + required: false, + description: + 'If this value is greater than zero a payment will be created along with the invoice.', + variables: true, + }, + { + label: 'Number', + key: 'number', + type: 'string', + required: false, + description: + 'The invoice number - is a unique alpha numeric number per invoice per company', + variables: true, + }, + { + label: 'Discount', + key: 'discount', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'PO Number', + key: 'poNumber', + type: 'string', + required: false, + description: 'The purchase order associated with this invoice', + variables: true, + }, + { + label: 'Date', + key: 'date', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Due Date', + key: 'dueDate', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Rate 1', + key: 'taxRate1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Name 1', + key: 'taxName1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Rate 2', + key: 'taxRate2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Name 2', + key: 'taxName2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Rate 3', + key: 'taxRate3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Name 3', + key: 'taxName3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Field 1', + key: 'customField1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Field 2', + key: 'customField2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Field 3', + key: 'customField3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Field 4', + key: 'customField4', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Surcharge 1', + key: 'customSurcharge1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Surcharge 2', + key: 'customSurcharge2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Surcharge 3', + key: 'customSurcharge3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Surcharge 4', + key: 'customSurcharge4', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Is Amount Discount', + key: 'isAmountDiscount', + type: 'dropdown', + required: false, + description: + 'By default the discount is applied as a percentage, enabling this applies the discount as a fixed amount.', + variables: true, + options: [ + { label: 'False', value: 'false' }, + { label: 'True', value: 'true' }, + ], + }, + { + label: 'Partial/Deposit', + key: 'partialDeposit', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Partial Due Date', + key: 'partialDueDate', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Cost', + key: 'lineItemCost', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Quatity', + key: 'lineItemQuantity', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Product', + key: 'lineItemProduct', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Discount', + key: 'lineItemDiscount', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Description', + key: 'lineItemDescription', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Tax Rate 1', + key: 'lineItemTaxRate1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Tax Name 1', + key: 'lineItemTaxName1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Tax Rate 2', + key: 'lineItemTaxRate2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Tax Name 2', + key: 'lineItemTaxName2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Tax Rate 3', + key: 'lineItemTaxRate3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Tax Name 3', + key: 'lineItemTaxName3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Custom Field 1', + key: 'lineItemCustomField1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Custom Field 2', + key: 'lineItemCustomField2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Custom Field 3', + key: 'lineItemCustomField3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Custom Field 4', + key: 'lineItemCustomField4', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Line Item Product Cost', + key: 'lineItemProductCost', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Public Notes', + key: 'publicNotes', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Private Notes', + key: 'privateNotes', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Terms', + key: 'terms', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Footer', + key: 'footer', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-invoice/index.js b/packages/backend/src/apps/invoice-ninja/actions/create-invoice/index.js new file mode 100644 index 0000000..7915ebf --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-invoice/index.js @@ -0,0 +1,127 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create invoice', + key: 'createInvoice', + description: 'Creates a new invoice.', + arguments: fields, + + async run($) { + const { + clientId, + sendEmail, + markSent, + paid, + amountPaid, + number, + discount, + poNumber, + date, + dueDate, + taxRate1, + taxName1, + taxRate2, + taxName2, + taxRate3, + taxName3, + customField1, + customField2, + customField3, + customField4, + customSurcharge1, + customSurcharge2, + customSurcharge3, + customSurcharge4, + isAmountDiscount, + partialDeposit, + partialDueDate, + lineItemCost, + lineItemQuantity, + lineItemProduct, + lineItemDiscount, + lineItemDescription, + lineItemTaxRate1, + lineItemTaxName1, + lineItemTaxRate2, + lineItemTaxName2, + lineItemTaxRate3, + lineItemTaxName3, + lineItemCustomField1, + lineItemCustomField2, + lineItemCustomField3, + lineItemCustomField4, + lineItemProductCost, + publicNotes, + privateNotes, + terms, + footer, + } = $.step.parameters; + + const paramFields = { + send_email: sendEmail, + mark_sent: markSent, + paid: paid, + amount_paid: amountPaid, + }; + + const params = filterProvidedFields(paramFields); + + const bodyFields = { + client_id: clientId, + number: number, + discount: discount, + po_number: poNumber, + date: date, + due_date: dueDate, + tax_rate1: taxRate1, + tax_name1: taxName1, + tax_rate2: taxRate2, + tax_name2: taxName2, + tax_rate3: taxRate3, + tax_name3: taxName3, + custom_value1: customField1, + custom_value2: customField2, + custom_value3: customField3, + custom_value4: customField4, + custom_surcharge1: customSurcharge1, + custom_surcharge2: customSurcharge2, + custom_surcharge3: customSurcharge3, + custom_surcharge4: customSurcharge4, + is_amount_discount: Boolean(isAmountDiscount), + partial: partialDeposit, + partial_due_date: partialDueDate, + line_items: [ + { + cost: lineItemCost, + quantity: lineItemQuantity, + product_key: lineItemProduct, + discount: lineItemDiscount, + notes: lineItemDescription, + tax_rate1: lineItemTaxRate1, + tax_name1: lineItemTaxName1, + tax_rate2: lineItemTaxRate2, + tax_name2: lineItemTaxName2, + tax_rate3: lineItemTaxRate3, + tax_name3: lineItemTaxName3, + custom_value1: lineItemCustomField1, + custom_value2: lineItemCustomField2, + custom_value3: lineItemCustomField3, + custom_value4: lineItemCustomField4, + product_cost: lineItemProductCost, + }, + ], + public_notes: publicNotes, + private_notes: privateNotes, + terms: terms, + footer: footer, + }; + + const body = filterProvidedFields(bodyFields); + + const response = await $.http.post('/v1/invoices', body, { params }); + + $.setActionItem({ raw: response.data.data }); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-payment/fields.js b/packages/backend/src/apps/invoice-ninja/actions/create-payment/fields.js new file mode 100644 index 0000000..608b89f --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-payment/fields.js @@ -0,0 +1,111 @@ +export const fields = [ + { + label: 'Client ID', + key: 'clientId', + type: 'dropdown', + required: true, + description: 'The ID of the client, not the name or email address.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listClients', + }, + ], + }, + }, + { + label: 'Payment Date', + key: 'paymentDate', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Invoice', + key: 'invoiceId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listInvoices', + }, + ], + }, + }, + { + label: 'Invoice Amount', + key: 'invoiceAmount', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Payment Type', + key: 'paymentType', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Bank Transfer', value: '1' }, + { label: 'Cash', value: '2' }, + { label: 'Debit', value: '3' }, + { label: 'ACH', value: '4' }, + { label: 'Visa Card', value: '5' }, + { label: 'MasterCard', value: '6' }, + { label: 'American Express', value: '7' }, + { label: 'Discover Card', value: '8' }, + { label: 'Diners Card', value: '9' }, + { label: 'EuroCard', value: '10' }, + { label: 'Nova', value: '11' }, + { label: 'Credit Card Other', value: '12' }, + { label: 'PayPal', value: '13' }, + { label: 'Google Wallet', value: '14' }, + { label: 'Check', value: '15' }, + { label: 'Carte Blanche', value: '16' }, + { label: 'UnionPay', value: '17' }, + { label: 'JCB', value: '18' }, + { label: 'Laser', value: '19' }, + { label: 'Maestro', value: '20' }, + { label: 'Solo', value: '21' }, + { label: 'Switch', value: '22' }, + { label: 'iZettle', value: '23' }, + { label: 'Swish', value: '24' }, + { label: 'Venmo', value: '25' }, + { label: 'Money Order', value: '26' }, + { label: 'Alipay', value: '27' }, + { label: 'Sofort', value: '28' }, + { label: 'SEPA', value: '29' }, + { label: 'GoCardless', value: '30' }, + { label: 'Bitcoin', value: '31' }, + ], + }, + { + label: 'Transfer Reference', + key: 'transferReference', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Private Notes', + key: 'privateNotes', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-payment/index.js b/packages/backend/src/apps/invoice-ninja/actions/create-payment/index.js new file mode 100644 index 0000000..4d4ea08 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-payment/index.js @@ -0,0 +1,42 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create payment', + key: 'createPayment', + description: 'Creates a new payment.', + arguments: fields, + + async run($) { + const { + clientId, + paymentDate, + invoiceId, + invoiceAmount, + paymentType, + transferReference, + privateNotes, + } = $.step.parameters; + + const bodyFields = { + client_id: clientId, + date: paymentDate, + invoices: [ + { + invoice_id: invoiceId, + amount: invoiceAmount, + }, + ], + type_id: paymentType, + transaction_reference: transferReference, + private_notes: privateNotes, + }; + + const body = filterProvidedFields(bodyFields); + + const response = await $.http.post('/v1/payments', body); + + $.setActionItem({ raw: response.data.data }); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-product/fields.js b/packages/backend/src/apps/invoice-ninja/actions/create-product/fields.js new file mode 100644 index 0000000..87b5fec --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-product/fields.js @@ -0,0 +1,114 @@ +export const fields = [ + { + label: 'Product Key', + key: 'productKey', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Notes', + key: 'notes', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Price', + key: 'price', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Quantity', + key: 'quantity', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Rate 1', + key: 'taxRate1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Name 1', + key: 'taxName1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Rate 2', + key: 'taxRate2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Name 2', + key: 'taxName2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Rate 3', + key: 'taxRate3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Tax Name 3', + key: 'taxName3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 1', + key: 'customValue1', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 2', + key: 'customValue2', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 3', + key: 'customValue3', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Custom Value 4', + key: 'customValue4', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/invoice-ninja/actions/create-product/index.js b/packages/backend/src/apps/invoice-ninja/actions/create-product/index.js new file mode 100644 index 0000000..5fa6dd6 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/create-product/index.js @@ -0,0 +1,52 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create product', + key: 'createProduct', + description: 'Creates a new product.', + arguments: fields, + + async run($) { + const { + productKey, + notes, + price, + quantity, + taxRate1, + taxName1, + taxRate2, + taxName2, + taxRate3, + taxName3, + customValue1, + customValue2, + customValue3, + customValue4, + } = $.step.parameters; + + const bodyFields = { + product_key: productKey, + notes: notes, + price: price, + quantity: quantity, + tax_rate1: taxRate1, + tax_name1: taxName1, + tax_rate2: taxRate2, + tax_name2: taxName2, + tax_rate3: taxRate3, + tax_name3: taxName3, + custom_value1: customValue1, + custom_value2: customValue2, + custom_value3: customValue3, + custom_value4: customValue4, + }; + + const body = filterProvidedFields(bodyFields); + + const response = await $.http.post('/v1/products', body); + + $.setActionItem({ raw: response.data.data }); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/actions/index.js b/packages/backend/src/apps/invoice-ninja/actions/index.js new file mode 100644 index 0000000..36d3821 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/actions/index.js @@ -0,0 +1,6 @@ +import createClient from './create-client/index.js'; +import createInvoice from './create-invoice/index.js'; +import createPayment from './create-payment/index.js'; +import createProduct from './create-product/index.js'; + +export default [createClient, createInvoice, createPayment, createProduct]; diff --git a/packages/backend/src/apps/invoice-ninja/assets/favicon.svg b/packages/backend/src/apps/invoice-ninja/assets/favicon.svg new file mode 100644 index 0000000..59d3f11 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/assets/favicon.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/backend/src/apps/invoice-ninja/auth/index.js b/packages/backend/src/apps/invoice-ninja/auth/index.js new file mode 100644 index 0000000..4e9a78c --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/auth/index.js @@ -0,0 +1,33 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'apiToken', + label: 'API Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Tokens can be created in the v5 app on Settings > Account Management', + clickToCopy: false, + }, + { + key: 'instanceUrl', + label: 'Invoice Ninja instance URL (optional)', + type: 'string', + required: false, + readOnly: false, + value: null, + placeholder: null, + description: "Leave this field blank if you're using hosted platform.", + clickToCopy: true, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/invoice-ninja/auth/is-still-verified.js b/packages/backend/src/apps/invoice-ninja/auth/is-still-verified.js new file mode 100644 index 0000000..270d415 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/auth/is-still-verified.js @@ -0,0 +1,9 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/invoice-ninja/auth/verify-credentials.js b/packages/backend/src/apps/invoice-ninja/auth/verify-credentials.js new file mode 100644 index 0000000..371876c --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/auth/verify-credentials.js @@ -0,0 +1,13 @@ +const verifyCredentials = async ($) => { + const { data } = await $.http.get('/v1/ping'); + + const screenName = [data.user_name, data.company_name] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/invoice-ninja/common/add-auth-header.js b/packages/backend/src/apps/invoice-ninja/common/add-auth-header.js new file mode 100644 index 0000000..a9bb734 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/common/add-auth-header.js @@ -0,0 +1,18 @@ +const addAuthHeader = ($, requestConfig) => { + const { instanceUrl } = $.auth.data; + + if (instanceUrl) { + requestConfig.baseURL = instanceUrl; + } + + requestConfig.headers['X-API-TOKEN'] = $.auth.data.apiToken; + + requestConfig.headers['X-Requested-With'] = 'XMLHttpRequest'; + + requestConfig.headers['Content-Type'] = + requestConfig.headers['Content-Type'] || 'application/json'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/invoice-ninja/common/filter-provided-fields.js b/packages/backend/src/apps/invoice-ninja/common/filter-provided-fields.js new file mode 100644 index 0000000..ceff6e2 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/common/filter-provided-fields.js @@ -0,0 +1,18 @@ +import isObject from 'lodash/isObject.js'; + +export function filterProvidedFields(body) { + return Object.keys(body).reduce((result, key) => { + const value = body[key]; + + if (isObject(value)) { + const filteredNestedObj = filterProvidedFields(value); + if (Object.keys(filteredNestedObj).length > 0) { + result[key] = filteredNestedObj; + } + } else if (body[key]) { + result[key] = value; + } + + return result; + }, {}); +} diff --git a/packages/backend/src/apps/invoice-ninja/common/set-base-url.js b/packages/backend/src/apps/invoice-ninja/common/set-base-url.js new file mode 100644 index 0000000..fc3252a --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/common/set-base-url.js @@ -0,0 +1,11 @@ +const setBaseUrl = ($, requestConfig) => { + const instanceUrl = $.auth.data.instanceUrl; + + if (instanceUrl) { + requestConfig.baseURL = instanceUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/invoice-ninja/dynamic-data/index.js b/packages/backend/src/apps/invoice-ninja/dynamic-data/index.js new file mode 100644 index 0000000..b3ef226 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listClients from './list-clients/index.js'; +import listInvoices from './list-invoices/index.js'; + +export default [listClients, listInvoices]; diff --git a/packages/backend/src/apps/invoice-ninja/dynamic-data/list-clients/index.js b/packages/backend/src/apps/invoice-ninja/dynamic-data/list-clients/index.js new file mode 100644 index 0000000..512ebda --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/dynamic-data/list-clients/index.js @@ -0,0 +1,31 @@ +export default { + name: 'List clients', + key: 'listClients', + + async run($) { + const clients = { + data: [], + }; + + const params = { + sort: 'created_at|desc', + }; + + const { + data: { data }, + } = await $.http.get('/v1/clients', { params }); + + if (!data?.length) { + return; + } + + for (const client of data) { + clients.data.push({ + value: client.id, + name: client.name, + }); + } + + return clients; + }, +}; diff --git a/packages/backend/src/apps/invoice-ninja/dynamic-data/list-invoices/index.js b/packages/backend/src/apps/invoice-ninja/dynamic-data/list-invoices/index.js new file mode 100644 index 0000000..15b6d06 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/dynamic-data/list-invoices/index.js @@ -0,0 +1,31 @@ +export default { + name: 'List invoices', + key: 'listInvoices', + + async run($) { + const invoices = { + data: [], + }; + + const params = { + sort: 'created_at|desc', + }; + + const { + data: { data }, + } = await $.http.get('/v1/invoices', { params }); + + if (!data?.length) { + return; + } + + for (const invoice of data) { + invoices.data.push({ + value: invoice.id, + name: invoice.number, + }); + } + + return invoices; + }, +}; diff --git a/packages/backend/src/apps/invoice-ninja/index.js b/packages/backend/src/apps/invoice-ninja/index.js new file mode 100644 index 0000000..891af09 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/index.js @@ -0,0 +1,23 @@ +import defineApp from '../../helpers/define-app.js'; +import setBaseUrl from './common/set-base-url.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Invoice Ninja', + key: 'invoice-ninja', + baseUrl: 'https://invoiceninja.com', + apiBaseUrl: 'https://invoicing.co/api', + iconUrl: '{BASE_URL}/apps/invoice-ninja/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/invoice-ninja/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/invoice-ninja/triggers/index.js b/packages/backend/src/apps/invoice-ninja/triggers/index.js new file mode 100644 index 0000000..8293af8 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/index.js @@ -0,0 +1,15 @@ +import newClients from './new-clients/index.js'; +import newCredits from './new-credits/index.js'; +import newInvoices from './new-invoices/index.js'; +import newPayments from './new-payments/index.js'; +import newProjects from './new-projects/index.js'; +import newQuotes from './new-quotes/index.js'; + +export default [ + newClients, + newCredits, + newInvoices, + newPayments, + newProjects, + newQuotes, +]; diff --git a/packages/backend/src/apps/invoice-ninja/triggers/new-clients/index.js b/packages/backend/src/apps/invoice-ninja/triggers/new-clients/index.js new file mode 100644 index 0000000..608541d --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/new-clients/index.js @@ -0,0 +1,54 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New clients', + key: 'newClients', + type: 'webhook', + description: 'Triggers when a new client is added.', + arguments: [], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const CREATE_CLIENT_EVENT_ID = '1'; + + const payload = { + target_url: $.webhookUrl, + event_id: CREATE_CLIENT_EVENT_ID, + format: 'JSON', + rest_method: 'post', + }; + + const response = await $.http.post('/v1/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v1/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/triggers/new-credits/index.js b/packages/backend/src/apps/invoice-ninja/triggers/new-credits/index.js new file mode 100644 index 0000000..13af343 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/new-credits/index.js @@ -0,0 +1,54 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New credits', + key: 'newCredits', + type: 'webhook', + description: 'Triggers when a new credit is added.', + arguments: [], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const CREATE_CREDIT_EVENT_ID = '27'; + + const payload = { + target_url: $.webhookUrl, + event_id: CREATE_CREDIT_EVENT_ID, + format: 'JSON', + rest_method: 'post', + }; + + const response = await $.http.post('/v1/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v1/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/triggers/new-invoices/index.js b/packages/backend/src/apps/invoice-ninja/triggers/new-invoices/index.js new file mode 100644 index 0000000..92e130f --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/new-invoices/index.js @@ -0,0 +1,54 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New invoices', + key: 'newInvoices', + type: 'webhook', + description: 'Triggers when a new invoice is added.', + arguments: [], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const CREATE_INVOICE_EVENT_ID = '2'; + + const payload = { + target_url: $.webhookUrl, + event_id: CREATE_INVOICE_EVENT_ID, + format: 'JSON', + rest_method: 'post', + }; + + const response = await $.http.post('/v1/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v1/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/triggers/new-payments/index.js b/packages/backend/src/apps/invoice-ninja/triggers/new-payments/index.js new file mode 100644 index 0000000..c505867 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/new-payments/index.js @@ -0,0 +1,54 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New payments', + key: 'newPayments', + type: 'webhook', + description: 'Triggers when a new payment is added.', + arguments: [], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const CREATE_PAYMENT_EVENT_ID = '4'; + + const payload = { + target_url: $.webhookUrl, + event_id: CREATE_PAYMENT_EVENT_ID, + format: 'JSON', + rest_method: 'post', + }; + + const response = await $.http.post('/v1/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v1/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/triggers/new-projects/index.js b/packages/backend/src/apps/invoice-ninja/triggers/new-projects/index.js new file mode 100644 index 0000000..5f6b64d --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/new-projects/index.js @@ -0,0 +1,54 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New projects', + key: 'newProjects', + type: 'webhook', + description: 'Triggers when a new project is added.', + arguments: [], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const CREATE_PROJECT_EVENT_ID = '25'; + + const payload = { + target_url: $.webhookUrl, + event_id: CREATE_PROJECT_EVENT_ID, + format: 'JSON', + rest_method: 'post', + }; + + const response = await $.http.post('/v1/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v1/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/invoice-ninja/triggers/new-quotes/index.js b/packages/backend/src/apps/invoice-ninja/triggers/new-quotes/index.js new file mode 100644 index 0000000..d2398d4 --- /dev/null +++ b/packages/backend/src/apps/invoice-ninja/triggers/new-quotes/index.js @@ -0,0 +1,54 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New quotes', + key: 'newQuotes', + type: 'webhook', + description: 'Triggers when a new quote is added.', + arguments: [], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const CREATE_QUOTE_EVENT_ID = '3'; + + const payload = { + target_url: $.webhookUrl, + event_id: CREATE_QUOTE_EVENT_ID, + format: 'JSON', + rest_method: 'post', + }; + + const response = await $.http.post('/v1/webhooks', payload); + + await $.flow.setRemoteWebhookId(response.data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v1/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/jotform/assets/favicon.svg b/packages/backend/src/apps/jotform/assets/favicon.svg new file mode 100644 index 0000000..9950004 --- /dev/null +++ b/packages/backend/src/apps/jotform/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/jotform/auth/index.js b/packages/backend/src/apps/jotform/auth/index.js new file mode 100644 index 0000000..9d5cf65 --- /dev/null +++ b/packages/backend/src/apps/jotform/auth/index.js @@ -0,0 +1,30 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'apiUrl', + label: 'API URL', + type: 'string', + required: false, + readOnly: false, + value: 'https://api.jotform.com', + placeholder: 'https://${subdomain}.jotform.com/api', + clickToCopy: true, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/jotform/auth/is-still-verified.js b/packages/backend/src/apps/jotform/auth/is-still-verified.js new file mode 100644 index 0000000..3986754 --- /dev/null +++ b/packages/backend/src/apps/jotform/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.username; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/jotform/auth/verify-credentials.js b/packages/backend/src/apps/jotform/auth/verify-credentials.js new file mode 100644 index 0000000..0acb407 --- /dev/null +++ b/packages/backend/src/apps/jotform/auth/verify-credentials.js @@ -0,0 +1,12 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const user = await getCurrentUser($); + + await $.auth.set({ + screenName: user.name, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/jotform/common/add-auth-header.js b/packages/backend/src/apps/jotform/common/add-auth-header.js new file mode 100644 index 0000000..4d6296f --- /dev/null +++ b/packages/backend/src/apps/jotform/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['APIKEY'] = `${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/jotform/common/get-current-user.js b/packages/backend/src/apps/jotform/common/get-current-user.js new file mode 100644 index 0000000..e0cf018 --- /dev/null +++ b/packages/backend/src/apps/jotform/common/get-current-user.js @@ -0,0 +1,7 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/user'); + const currentUser = response.data.content; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/jotform/common/set-base-url.js b/packages/backend/src/apps/jotform/common/set-base-url.js new file mode 100644 index 0000000..d2f6851 --- /dev/null +++ b/packages/backend/src/apps/jotform/common/set-base-url.js @@ -0,0 +1,11 @@ +const setBaseUrl = ($, requestConfig) => { + if ($.auth.data.apiUrl) { + requestConfig.baseURL = $.auth.data.apiUrl; + } else if ($.app.apiBaseUrl) { + requestConfig.baseURL = $.app.apiBaseUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/jotform/dynamic-data/index.js b/packages/backend/src/apps/jotform/dynamic-data/index.js new file mode 100644 index 0000000..0a58430 --- /dev/null +++ b/packages/backend/src/apps/jotform/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listForms from './list-forms/index.js'; + +export default [listForms]; diff --git a/packages/backend/src/apps/jotform/dynamic-data/list-forms/index.js b/packages/backend/src/apps/jotform/dynamic-data/list-forms/index.js new file mode 100644 index 0000000..c96f34e --- /dev/null +++ b/packages/backend/src/apps/jotform/dynamic-data/list-forms/index.js @@ -0,0 +1,41 @@ +export default { + name: 'List forms', + key: 'listForms', + + async run($) { + const forms = { + data: [], + }; + let hasMore = false; + + const params = { + limit: 1000, + offset: 0, + orderby: 'created_at', + }; + + do { + const { data } = await $.http.get('/user/forms', { params }); + params.offset = params.offset + params.limit; + + if (data.content?.length) { + for (const form of data.content) { + if (form.status === 'ENABLED') { + forms.data.push({ + value: form.id, + name: form.title, + }); + } + } + } + + if (data.resultSet.count >= data.resultSet.limit) { + hasMore = true; + } else { + hasMore = false; + } + } while (hasMore); + + return forms; + }, +}; diff --git a/packages/backend/src/apps/jotform/index.js b/packages/backend/src/apps/jotform/index.js new file mode 100644 index 0000000..0611dad --- /dev/null +++ b/packages/backend/src/apps/jotform/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import setBaseUrl from './common/set-base-url.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Jotform', + key: 'jotform', + iconUrl: '{BASE_URL}/apps/jotform/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/jotform/connection', + supportsConnections: true, + baseUrl: 'https://www.jotform.com', + apiBaseUrl: 'https://api.jotform.com', + primaryColor: '#FF6100', + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/jotform/triggers/index.js b/packages/backend/src/apps/jotform/triggers/index.js new file mode 100644 index 0000000..b25ac2c --- /dev/null +++ b/packages/backend/src/apps/jotform/triggers/index.js @@ -0,0 +1,3 @@ +import newSubmissions from './new-submissions/index.js'; + +export default [newSubmissions]; diff --git a/packages/backend/src/apps/jotform/triggers/new-submissions/index.js b/packages/backend/src/apps/jotform/triggers/new-submissions/index.js new file mode 100644 index 0000000..e5c9946 --- /dev/null +++ b/packages/backend/src/apps/jotform/triggers/new-submissions/index.js @@ -0,0 +1,109 @@ +import Crypto from 'crypto'; +import { URLSearchParams } from 'url'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New submissions', + key: 'newSubmissions', + type: 'webhook', + description: + 'Triggers when a new submission has been added to a specific form.', + arguments: [ + { + label: 'Form', + key: 'formId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listForms', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const sampleEventData = { + ip: '127.0.0.1', + type: 'WEB', + appID: '', + event: '', + action: '', + formID: Crypto.randomUUID(), + parent: '', + pretty: 'Name:test, E-mail:user@automatisch.io', + teamID: '', + unread: '', + product: '', + subject: '', + isSilent: '', + username: 'username', + deviceIDs: 'Array', + formTitle: 'Opt-In Form-Get Free Email Updates!', + fromTable: '', + customBody: '', + documentID: '', + rawRequest: '', + webhookURL: '', + customTitle: '', + trackAction: 'Array', + customParams: '', + submissionID: Crypto.randomUUID(), + customBodyParams: 'Array', + customTitleParams: 'Array', + }; + + const dataItem = { + raw: sampleEventData, + meta: { + internalId: sampleEventData.submissionID, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const formId = $.step.parameters.formId; + + const params = new URLSearchParams({ + webhookURL: $.webhookUrl, + }); + + const { data } = await $.http.post( + `/form/${formId}/webhooks`, + params.toString() + ); + + await $.flow.setRemoteWebhookId(data.content[0]); + }, + + async unregisterHook($) { + const formId = $.step.parameters.formId; + + const { data } = await $.http.get(`/form/${formId}/webhooks`); + + const webhookURLs = Object.values(data.content); + const webhookId = webhookURLs.findIndex((url) => url === $.webhookUrl); + + await $.http.delete(`/form/${formId}/webhooks/${webhookId}`); + }, +}); diff --git a/packages/backend/src/apps/mailchimp/actions/create-campaign/index.js b/packages/backend/src/apps/mailchimp/actions/create-campaign/index.js new file mode 100644 index 0000000..f622e15 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/actions/create-campaign/index.js @@ -0,0 +1,180 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create campaign', + key: 'createCampaign', + description: 'Creates a new campaign draft.', + arguments: [ + { + label: 'Campaign Name', + key: 'campaignName', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Audience', + key: 'audienceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAudiences', + }, + ], + }, + }, + { + label: 'Segment or Tag', + key: 'segmentOrTagId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.audienceId'], + description: + 'Choose the specific segment or tag to which you"d like to direct the campaign. If no segment or tag is chosen, the campaign will be distributed to the entire audience previously selected.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSegmentsOrTags', + }, + { + name: 'parameters.audienceId', + value: '{parameters.audienceId}', + }, + ], + }, + }, + { + label: 'Email Subject', + key: 'emailSubject', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Preview Text', + key: 'previewText', + type: 'string', + required: false, + description: + 'The snippet will be visible in the inbox following the subject line.', + variables: true, + }, + { + label: 'From Name', + key: 'fromName', + type: 'string', + required: true, + description: 'The "from" name on the campaign (not an email address).', + variables: true, + }, + { + label: 'From Email Address', + key: 'fromEmailAddress', + type: 'string', + required: true, + description: 'The reply-to email address for the campaign.', + variables: true, + }, + { + label: 'To Name', + key: 'toName', + type: 'string', + required: false, + description: + 'Supports *|MERGETAGS|* for recipient name, such as *|FNAME|*, *|LNAME|*, *|FNAME|* *|LNAME|*, etc.', + variables: true, + }, + { + label: 'Template', + key: 'templateId', + type: 'dropdown', + required: false, + description: + 'Select either a template or provide HTML email content, you cannot provide both. If both fields are left blank, the campaign draft will have no content.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTemplates', + }, + ], + }, + }, + { + label: 'Email Content (HTML)', + key: 'emailContent', + type: 'string', + required: false, + description: + 'Select either a template or provide HTML email content, you cannot provide both. If both fields are left blank, the campaign draft will have no content.', + variables: true, + }, + ], + + async run($) { + const { + campaignName, + audienceId, + segmentOrTagId, + emailSubject, + previewText, + fromName, + fromEmailAddress, + toName, + templateId, + emailContent, + } = $.step.parameters; + + const body = { + type: 'regular', + recipients: { + list_id: audienceId, + segment_opts: { + saved_segment_id: Number(segmentOrTagId), + }, + }, + settings: { + subject_line: emailSubject, + reply_to: fromEmailAddress, + title: campaignName, + preview_text: previewText, + from_name: fromName, + to_name: toName, + }, + }; + + const { data: campaign } = await $.http.post('/3.0/campaigns', body); + + const campaignBody = { + template: { + id: Number(templateId), + }, + html: emailContent, + }; + + const { data } = await $.http.put( + `/3.0/campaigns/${campaign.id}/content`, + campaignBody + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/mailchimp/actions/index.js b/packages/backend/src/apps/mailchimp/actions/index.js new file mode 100644 index 0000000..853ccb5 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/actions/index.js @@ -0,0 +1,4 @@ +import createCampaign from './create-campaign/index.js'; +import sendCampaign from './send-campaign/index.js'; + +export default [createCampaign, sendCampaign]; diff --git a/packages/backend/src/apps/mailchimp/actions/send-campaign/index.js b/packages/backend/src/apps/mailchimp/actions/send-campaign/index.js new file mode 100644 index 0000000..1c954cb --- /dev/null +++ b/packages/backend/src/apps/mailchimp/actions/send-campaign/index.js @@ -0,0 +1,39 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send campaign', + key: 'sendCampaign', + description: 'Sends a campaign draft.', + arguments: [ + { + label: 'Campaign', + key: 'campaignId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCampaigns', + }, + ], + }, + }, + ], + + async run($) { + const campaignId = $.step.parameters.campaignId; + + await $.http.post(`/3.0/campaigns/${campaignId}/actions/send`); + + $.setActionItem({ + raw: { + output: 'sent', + }, + }); + }, +}); diff --git a/packages/backend/src/apps/mailchimp/assets/favicon.svg b/packages/backend/src/apps/mailchimp/assets/favicon.svg new file mode 100644 index 0000000..55af85d --- /dev/null +++ b/packages/backend/src/apps/mailchimp/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/mailchimp/auth/generate-auth-url.js b/packages/backend/src/apps/mailchimp/auth/generate-auth-url.js new file mode 100644 index 0000000..380fe61 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/auth/generate-auth-url.js @@ -0,0 +1,19 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + response_type: 'code', + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + }); + + const url = `https://login.mailchimp.com/oauth2/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/mailchimp/auth/index.js b/packages/backend/src/apps/mailchimp/auth/index.js new file mode 100644 index 0000000..d69cfd4 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/auth/index.js @@ -0,0 +1,46 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/mailchimp/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Mailchimp, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/mailchimp/auth/is-still-verified.js b/packages/backend/src/apps/mailchimp/auth/is-still-verified.js new file mode 100644 index 0000000..b750ce1 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.user_id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/mailchimp/auth/verify-credentials.js b/packages/backend/src/apps/mailchimp/auth/verify-credentials.js new file mode 100644 index 0000000..efe7a04 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/auth/verify-credentials.js @@ -0,0 +1,40 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + redirect_uri: redirectUri, + code: $.auth.data.code, + }); + + const { data } = await $.http.post( + 'https://login.mailchimp.com/oauth2/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + serverPrefix: currentUser.dc, + screenName: currentUser.login.login_name, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/mailchimp/common/add-auth-header.js b/packages/backend/src/apps/mailchimp/common/add-auth-header.js new file mode 100644 index 0000000..141e1bd --- /dev/null +++ b/packages/backend/src/apps/mailchimp/common/add-auth-header.js @@ -0,0 +1,12 @@ +const addAuthHeader = ($, requestConfig) => { + if ( + !requestConfig.additionalProperties?.skipAddingAuthHeader && + $.auth.data?.accessToken + ) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/mailchimp/common/get-current-user.js b/packages/backend/src/apps/mailchimp/common/get-current-user.js new file mode 100644 index 0000000..1c27f1a --- /dev/null +++ b/packages/backend/src/apps/mailchimp/common/get-current-user.js @@ -0,0 +1,18 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://login.mailchimp.com/oauth2/metadata', + { + headers: { + Authorization: `OAuth ${$.auth.data.accessToken}`, + }, + additionalProperties: { + skipAddingAuthHeader: true, + skipAddingBaseUrl: true, + }, + } + ); + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/mailchimp/common/set-base-url.js b/packages/backend/src/apps/mailchimp/common/set-base-url.js new file mode 100644 index 0000000..6ace234 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/common/set-base-url.js @@ -0,0 +1,10 @@ +const setBaseUrl = ($, requestConfig) => { + const serverPrefix = $.auth.data.serverPrefix; + if (!requestConfig.additionalProperties?.skipAddingBaseUrl && serverPrefix) { + requestConfig.baseURL = `https://${serverPrefix}.api.mailchimp.com`; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/mailchimp/dynamic-data/index.js b/packages/backend/src/apps/mailchimp/dynamic-data/index.js new file mode 100644 index 0000000..1e7045f --- /dev/null +++ b/packages/backend/src/apps/mailchimp/dynamic-data/index.js @@ -0,0 +1,6 @@ +import listAudiences from './list-audiences/index.js'; +import listCampaigns from './list-campaigns/index.js'; +import listTags from './list-segments-or-tags/index.js'; +import listTemplates from './list-templates/index.js'; + +export default [listAudiences, listCampaigns, listTags, listTemplates]; diff --git a/packages/backend/src/apps/mailchimp/dynamic-data/list-audiences/index.js b/packages/backend/src/apps/mailchimp/dynamic-data/list-audiences/index.js new file mode 100644 index 0000000..0564605 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/dynamic-data/list-audiences/index.js @@ -0,0 +1,40 @@ +export default { + name: 'List audiences', + key: 'listAudiences', + + async run($) { + const audiences = { + data: [], + }; + let hasMore = false; + + const params = { + sort_field: 'date_created', + sort_dir: 'DESC', + count: 1000, + offset: 0, + }; + + do { + const { data } = await $.http.get('/3.0/lists', { params }); + params.offset = params.offset + params.count; + + if (data?.lists) { + for (const audience of data.lists) { + audiences.data.push({ + value: audience.id, + name: audience.name, + }); + } + } + + if (data.total_items > params.offset) { + hasMore = true; + } else { + hasMore = false; + } + } while (hasMore); + + return audiences; + }, +}; diff --git a/packages/backend/src/apps/mailchimp/dynamic-data/list-campaigns/index.js b/packages/backend/src/apps/mailchimp/dynamic-data/list-campaigns/index.js new file mode 100644 index 0000000..eef7752 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/dynamic-data/list-campaigns/index.js @@ -0,0 +1,42 @@ +export default { + name: 'List campaigns', + key: 'listCampaigns', + + async run($) { + const campaigns = { + data: [], + }; + let hasMore = false; + const audienceId = $.step.parameters.audienceId; + + const params = { + list_id: audienceId, + sort_field: 'create_time', + sort_dir: 'DESC', + count: 1000, + offset: 0, + }; + + do { + const { data } = await $.http.get('/3.0/campaigns', { params }); + params.offset = params.offset + params.count; + + if (data?.campaigns) { + for (const campaign of data.campaigns) { + campaigns.data.push({ + value: campaign.id, + name: campaign.settings.title || campaign.settings.subject_line || 'Unnamed campaign', + }); + } + } + + if (data.total_items > params.offset) { + hasMore = true; + } else { + hasMore = false; + } + } while (hasMore); + + return campaigns; + }, +}; diff --git a/packages/backend/src/apps/mailchimp/dynamic-data/list-segments-or-tags/index.js b/packages/backend/src/apps/mailchimp/dynamic-data/list-segments-or-tags/index.js new file mode 100644 index 0000000..282345c --- /dev/null +++ b/packages/backend/src/apps/mailchimp/dynamic-data/list-segments-or-tags/index.js @@ -0,0 +1,44 @@ +export default { + name: 'List segments or tags', + key: 'listSegmentsOrTags', + + async run($) { + const segmentsOrTags = { + data: [], + }; + const audienceId = $.step.parameters.audienceId; + + if (!audienceId) { + return segmentsOrTags; + } + + const { + data: { tags: allTags }, + } = await $.http.get(`/3.0/lists/${audienceId}/tag-search`); + + const { + data: { segments }, + } = await $.http.get(`/3.0/lists/${audienceId}/segments`); + + const mergedArray = [...allTags, ...segments].reduce( + (accumulator, current) => { + if (!accumulator.some((item) => item.id === current.id)) { + accumulator.push(current); + } + return accumulator; + }, + [] + ); + + if (mergedArray?.length) { + for (const tagOrSegment of mergedArray) { + segmentsOrTags.data.push({ + value: tagOrSegment.id, + name: tagOrSegment.name, + }); + } + } + + return segmentsOrTags; + }, +}; diff --git a/packages/backend/src/apps/mailchimp/dynamic-data/list-templates/index.js b/packages/backend/src/apps/mailchimp/dynamic-data/list-templates/index.js new file mode 100644 index 0000000..9f67f81 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/dynamic-data/list-templates/index.js @@ -0,0 +1,30 @@ +export default { + name: 'List templates', + key: 'listTemplates', + + async run($) { + const templates = { + data: [], + }; + + const params = { + sort_field: 'date_created', + sort_dir: 'DESC', + count: 1000, + offset: 0, + }; + + const { data } = await $.http.get('/3.0/templates', { params }); + + if (data?.templates) { + for (const template of data.templates) { + templates.data.push({ + value: template.id, + name: template.name, + }); + } + } + + return templates; + }, +}; diff --git a/packages/backend/src/apps/mailchimp/index.js b/packages/backend/src/apps/mailchimp/index.js new file mode 100644 index 0000000..eafb9d5 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/index.js @@ -0,0 +1,23 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Mailchimp', + key: 'mailchimp', + baseUrl: 'https://mailchimp.com', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/mailchimp/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/mailchimp/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + dynamicData, + actions, +}); diff --git a/packages/backend/src/apps/mailchimp/triggers/email-opened/index.js b/packages/backend/src/apps/mailchimp/triggers/email-opened/index.js new file mode 100644 index 0000000..16fc4a5 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/triggers/email-opened/index.js @@ -0,0 +1,101 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Email opened', + key: 'emailOpened', + pollInterval: 15, + description: + 'Triggers when a recipient opens an email as part of a particular campaign.', + arguments: [ + { + label: 'Audience', + key: 'audienceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAudiences', + }, + ], + }, + }, + { + label: 'Campaign Type', + key: 'campaignType', + type: 'dropdown', + required: true, + description: '', + variables: true, + options: [ + { + label: 'Campaign', + value: 'campaign', + }, + ], + }, + { + label: 'Campaign', + key: 'campaignId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.audienceId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCampaigns', + }, + { + name: 'parameters.audienceId', + value: '{parameters.audienceId}', + }, + ], + }, + }, + ], + + async run($) { + const campaignId = $.step.parameters.campaignId; + let hasMore = false; + + const params = { + count: 1000, + offset: 0, + }; + + do { + const { data } = await $.http.get( + `/3.0/reports/${campaignId}/open-details`, + { params } + ); + params.offset = params.offset + params.count; + + if (data.members?.length) { + for (const member of data.members) { + $.pushTriggerItem({ + raw: member, + meta: { + internalId: member.email_id, + }, + }); + } + } + + if (data.total_items > params.offset) { + hasMore = true; + } else { + hasMore = false; + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/mailchimp/triggers/index.js b/packages/backend/src/apps/mailchimp/triggers/index.js new file mode 100644 index 0000000..d659e31 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/triggers/index.js @@ -0,0 +1,5 @@ +import emailOpened from './email-opened/index.js'; +import newSubscribers from './new-subscribers/index.js'; +import newUnsubscribers from './new-unsubscribers/index.js'; + +export default [emailOpened, newSubscribers, newUnsubscribers]; diff --git a/packages/backend/src/apps/mailchimp/triggers/new-subscribers/index.js b/packages/backend/src/apps/mailchimp/triggers/new-subscribers/index.js new file mode 100644 index 0000000..6fe8e53 --- /dev/null +++ b/packages/backend/src/apps/mailchimp/triggers/new-subscribers/index.js @@ -0,0 +1,105 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New subscribers', + key: 'newSubscribers', + type: 'webhook', + description: 'Triggers when a new subscriber is appended to an audience.', + arguments: [ + { + label: 'Audience', + key: 'audienceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAudiences', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const audienceId = $.step.parameters.audienceId; + + const computedWebhookEvent = { + data: { + id: Crypto.randomUUID(), + email: 'user@automatisch.io', + ip_opt: '127.0.0.1', + merges: { + EMAIL: 'user@automatisch.io', + FNAME: 'FNAME', + LNAME: 'LNAME', + PHONE: '', + ADDRESS: '', + BIRTHDAY: '', + }, + web_id: Crypto.randomUUID(), + list_id: audienceId, + email_type: 'html', + }, + type: 'subscribe', + fired_at: new Date().toLocaleString(), + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: '', + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const audienceId = $.step.parameters.audienceId; + + const payload = { + url: $.webhookUrl, + events: { + subscribe: true, + }, + sources: { + user: true, + admin: true, + api: true, + }, + }; + + const response = await $.http.post( + `/3.0/lists/${audienceId}/webhooks`, + payload + ); + + await $.flow.setRemoteWebhookId(response.data.id); + }, + + async unregisterHook($) { + const audienceId = $.step.parameters.audienceId; + + await $.http.delete( + `/3.0/lists/${audienceId}/webhooks/${$.flow.remoteWebhookId}` + ); + }, +}); diff --git a/packages/backend/src/apps/mailchimp/triggers/new-unsubscribers/index.js b/packages/backend/src/apps/mailchimp/triggers/new-unsubscribers/index.js new file mode 100644 index 0000000..f906efa --- /dev/null +++ b/packages/backend/src/apps/mailchimp/triggers/new-unsubscribers/index.js @@ -0,0 +1,108 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New unsubscribers', + key: 'newUnsubscribers', + type: 'webhook', + description: 'Triggers when any existing subscriber opts out of an audience.', + arguments: [ + { + label: 'Audience', + key: 'audienceId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAudiences', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const audienceId = $.step.parameters.audienceId; + + const computedWebhookEvent = { + data: { + id: Crypto.randomUUID(), + email: 'user@automatisch.io', + action: 'unsub', + ip_opt: '127.0.0.1', + merges: { + EMAIL: 'user@automatisch.io', + FNAME: 'FNAME', + LNAME: 'LNAME', + PHONE: '', + ADDRESS: '', + BIRTHDAY: '', + }, + reason: 'manual', + web_id: Crypto.randomUUID(), + list_id: audienceId, + email_type: 'html', + campaign_id: Crypto.randomUUID(), + }, + type: 'unsubscribe', + fired_at: new Date().toLocaleString(), + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: '', + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const audienceId = $.step.parameters.audienceId; + + const payload = { + url: $.webhookUrl, + events: { + unsubscribe: true, + }, + sources: { + user: true, + admin: true, + api: true, + }, + }; + + const response = await $.http.post( + `/3.0/lists/${audienceId}/webhooks`, + payload + ); + + await $.flow.setRemoteWebhookId(response.data.id); + }, + + async unregisterHook($) { + const audienceId = $.step.parameters.audienceId; + + await $.http.delete( + `/3.0/lists/${audienceId}/webhooks/${$.flow.remoteWebhookId}` + ); + }, +}); diff --git a/packages/backend/src/apps/mailerlite/assets/favicon.svg b/packages/backend/src/apps/mailerlite/assets/favicon.svg new file mode 100644 index 0000000..b382453 --- /dev/null +++ b/packages/backend/src/apps/mailerlite/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/mailerlite/auth/index.js b/packages/backend/src/apps/mailerlite/auth/index.js new file mode 100644 index 0000000..0c244e3 --- /dev/null +++ b/packages/backend/src/apps/mailerlite/auth/index.js @@ -0,0 +1,33 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'MailerLite API key of your account.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/mailerlite/auth/is-still-verified.js b/packages/backend/src/apps/mailerlite/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/mailerlite/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/mailerlite/auth/verify-credentials.js b/packages/backend/src/apps/mailerlite/auth/verify-credentials.js new file mode 100644 index 0000000..8e9d002 --- /dev/null +++ b/packages/backend/src/apps/mailerlite/auth/verify-credentials.js @@ -0,0 +1,10 @@ +const verifyCredentials = async ($) => { + await $.http.get('/campaigns'); + + await $.auth.set({ + screenName: $.auth.data.screenName, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/mailerlite/common/add-auth-header.js b/packages/backend/src/apps/mailerlite/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/mailerlite/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/mailerlite/index.js b/packages/backend/src/apps/mailerlite/index.js new file mode 100644 index 0000000..1a651f3 --- /dev/null +++ b/packages/backend/src/apps/mailerlite/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'MailerLite', + key: 'mailerlite', + iconUrl: '{BASE_URL}/apps/mailerlite/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/mailerlite/connection', + supportsConnections: true, + baseUrl: 'https://www.mailerlite.com', + apiBaseUrl: 'https://connect.mailerlite.com/api', + primaryColor: '#09C269', + beforeRequest: [addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/mailerlite/triggers/campaign-sent/index.js b/packages/backend/src/apps/mailerlite/triggers/campaign-sent/index.js new file mode 100644 index 0000000..db4404d --- /dev/null +++ b/packages/backend/src/apps/mailerlite/triggers/campaign-sent/index.js @@ -0,0 +1,55 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Campaign sent', + key: 'campaignSent', + type: 'webhook', + description: 'Triggers when a campaign has been activated.', + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const computedWebhookEvent = { + id: Crypto.randomUUID(), + date: new Date().toISOString(), + name: 'Name', + preview_url: '', + total_recipients: 1, + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const payload = { + name: $.flow.id, + events: ['campaign.sent'], + url: $.webhookUrl, + }; + + const { data } = await $.http.post('/webhooks', payload); + + await $.flow.setRemoteWebhookId(data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/mailerlite/triggers/index.js b/packages/backend/src/apps/mailerlite/triggers/index.js new file mode 100644 index 0000000..77ddbce --- /dev/null +++ b/packages/backend/src/apps/mailerlite/triggers/index.js @@ -0,0 +1,11 @@ +import campaignSent from './campaign-sent/index.js'; +import spamComplaint from './spam-complaint/index.js'; +import subscriberCreated from './subscriber-created/index.js'; +import subscriberUnsubscribed from './subscriber-unsubscribed/index.js'; + +export default [ + campaignSent, + spamComplaint, + subscriberCreated, + subscriberUnsubscribed, +]; diff --git a/packages/backend/src/apps/mailerlite/triggers/spam-complaint/index.js b/packages/backend/src/apps/mailerlite/triggers/spam-complaint/index.js new file mode 100644 index 0000000..074bfea --- /dev/null +++ b/packages/backend/src/apps/mailerlite/triggers/spam-complaint/index.js @@ -0,0 +1,78 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Spam complaint', + key: 'spamComplaint', + type: 'webhook', + description: 'Triggers when a subscriber reports an email as spam.', + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const computedWebhookEvent = { + id: Crypto.randomUUID(), + sent: 1, + email: 'user@automatisch.io', + fields: { + city: 'City', + name: 'Name', + phone: '', + state: 'State', + z_i_p: null, + company: 'Company', + country: 'Country', + last_name: 'Last Name', + }, + source: '', + status: 'junk', + optin_ip: null, + forget_at: null, + open_rate: 0, + click_rate: 0, + created_at: new Date().toISOString(), + deleted_at: null, + ip_address: null, + updated_at: new Date().toISOString(), + opens_count: 0, + opted_in_at: null, + clicks_count: 0, + subscribed_at: new Date().toISOString(), + unsubscribed_at: null, + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const payload = { + name: $.flow.id, + events: ['subscriber.spam_reported'], + url: $.webhookUrl, + }; + + const { data } = await $.http.post('/webhooks', payload); + + await $.flow.setRemoteWebhookId(data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/mailerlite/triggers/subscriber-created/index.js b/packages/backend/src/apps/mailerlite/triggers/subscriber-created/index.js new file mode 100644 index 0000000..4f4e05d --- /dev/null +++ b/packages/backend/src/apps/mailerlite/triggers/subscriber-created/index.js @@ -0,0 +1,78 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Subscriber created', + key: 'subscriberCreated', + type: 'webhook', + description: 'Triggers when a new subscriber is added to your mailing list.', + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const computedWebhookEvent = { + id: Crypto.randomUUID(), + sent: null, + email: 'user@automatisch.io', + fields: { + city: 'City', + name: 'Name', + phone: '', + state: 'State', + z_i_p: null, + company: 'Company', + country: 'Country', + last_name: 'Last Name', + }, + source: 'manual', + status: 'active', + optin_ip: null, + forget_at: null, + open_rate: 0, + click_rate: 0, + created_at: new Date().toISOString(), + deleted_at: null, + ip_address: null, + updated_at: new Date().toISOString(), + opens_count: null, + opted_in_at: null, + clicks_count: null, + subscribed_at: new Date().toISOString(), + unsubscribed_at: null, + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const payload = { + name: $.flow.id, + events: ['subscriber.created'], + url: $.webhookUrl, + }; + + const { data } = await $.http.post('/webhooks', payload); + + await $.flow.setRemoteWebhookId(data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/mailerlite/triggers/subscriber-unsubscribed/index.js b/packages/backend/src/apps/mailerlite/triggers/subscriber-unsubscribed/index.js new file mode 100644 index 0000000..5f90b09 --- /dev/null +++ b/packages/backend/src/apps/mailerlite/triggers/subscriber-unsubscribed/index.js @@ -0,0 +1,79 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Subscriber unsubscribed', + key: 'subscriberUnsubscribed', + type: 'webhook', + description: + 'Triggers when a subscriber has unsubscribed from your mailing list.', + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const computedWebhookEvent = { + id: Crypto.randomUUID(), + sent: 1, + email: 'user@automatisch.io', + fields: { + city: 'City', + name: 'Name', + phone: '', + state: 'State', + z_i_p: null, + company: 'Company', + country: 'Country', + last_name: 'Last Name', + }, + source: 'manual', + status: 'unsubscribed', + optin_ip: null, + forget_at: null, + open_rate: 100, + click_rate: 0, + created_at: new Date().toISOString(), + deleted_at: null, + ip_address: null, + updated_at: new Date().toISOString(), + opens_count: 1, + opted_in_at: null, + clicks_count: 0, + subscribed_at: new Date().toISOString(), + unsubscribed_at: new Date().toISOString(), + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const payload = { + name: $.flow.id, + events: ['subscriber.unsubscribed'], + url: $.webhookUrl, + }; + + const { data } = await $.http.post('/webhooks', payload); + + await $.flow.setRemoteWebhookId(data.data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/mattermost/actions/index.js b/packages/backend/src/apps/mattermost/actions/index.js new file mode 100644 index 0000000..93ee03b --- /dev/null +++ b/packages/backend/src/apps/mattermost/actions/index.js @@ -0,0 +1,3 @@ +import sendMessageToChannel from './send-a-message-to-channel/index.js'; + +export default [sendMessageToChannel]; diff --git a/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.js b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.js new file mode 100644 index 0000000..30cdc4b --- /dev/null +++ b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/index.js @@ -0,0 +1,42 @@ +import defineAction from '../../../../helpers/define-action.js'; +import postMessage from './post-message.js'; + +export default defineAction({ + name: 'Send a message to channel', + key: 'sendMessageToChannel', + description: 'Sends a message to a channel you specify.', + arguments: [ + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + required: true, + description: 'Pick a channel to send the message to.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listChannels', + }, + ], + }, + }, + { + label: 'Message text', + key: 'message', + type: 'string', + required: true, + description: 'The content of your new message.', + variables: true, + }, + ], + + async run($) { + const message = await postMessage($); + + return message; + }, +}); diff --git a/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.js b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.js new file mode 100644 index 0000000..e4a915d --- /dev/null +++ b/packages/backend/src/apps/mattermost/actions/send-a-message-to-channel/post-message.js @@ -0,0 +1,20 @@ +const postMessage = async ($) => { + const { parameters } = $.step; + const channel_id = parameters.channel; + const message = parameters.message; + + const data = { + channel_id, + message, + }; + + const response = await $.http.post('/api/v4/posts', data); + + const actionData = { + raw: response?.data, + }; + + $.setActionItem(actionData); +}; + +export default postMessage; diff --git a/packages/backend/src/apps/mattermost/assets/favicon.svg b/packages/backend/src/apps/mattermost/assets/favicon.svg new file mode 100644 index 0000000..1d5bf91 --- /dev/null +++ b/packages/backend/src/apps/mattermost/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/mattermost/auth/generate-auth-url.js b/packages/backend/src/apps/mattermost/auth/generate-auth-url.js new file mode 100644 index 0000000..ba9b1ac --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/generate-auth-url.js @@ -0,0 +1,17 @@ +import { URL, URLSearchParams } from 'url'; +import getBaseUrl from '../common/get-base-url.js'; + +export default async function generateAuthUrl($) { + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: $.auth.data.oAuthRedirectUrl, + response_type: 'code', + }); + + const baseUrl = getBaseUrl($); + const path = `/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url: new URL(path, baseUrl).toString(), + }); +} diff --git a/packages/backend/src/apps/mattermost/auth/index.js b/packages/backend/src/apps/mattermost/auth/index.js new file mode 100644 index 0000000..4dd50ae --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/index.js @@ -0,0 +1,57 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/mattermost/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Mattermost OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'instanceUrl', + label: 'Mattermost instance URL', + type: 'string', + required: false, + readOnly: false, + value: null, + placeholder: null, + description: 'Your Mattermost instance URL', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client id', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/mattermost/auth/is-still-verified.js b/packages/backend/src/apps/mattermost/auth/is-still-verified.js new file mode 100644 index 0000000..1b9d46d --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/mattermost/auth/verify-credentials.js b/packages/backend/src/apps/mattermost/auth/verify-credentials.js new file mode 100644 index 0000000..e5c8396 --- /dev/null +++ b/packages/backend/src/apps/mattermost/auth/verify-credentials.js @@ -0,0 +1,43 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }; + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', // This is not documented yet required + }; + const response = await $.http.post('/oauth/access_token', null, { + params, + headers, + }); + + const { + data: { access_token, refresh_token, scope, token_type }, + } = response; + + $.auth.data.accessToken = response.data.access_token; + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + accessToken: access_token, + refreshToken: refresh_token, + scope: scope, + tokenType: token_type, + userId: currentUser.id, + screenName: currentUser.username, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/mattermost/common/add-auth-header.js b/packages/backend/src/apps/mattermost/common/add-auth-header.js new file mode 100644 index 0000000..b44932c --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/add-auth-header.js @@ -0,0 +1,10 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers = requestConfig.headers || {}; + requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/mattermost/common/add-x-requested-with-header.js b/packages/backend/src/apps/mattermost/common/add-x-requested-with-header.js new file mode 100644 index 0000000..2e23287 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/add-x-requested-with-header.js @@ -0,0 +1,9 @@ +const addXRequestedWithHeader = ($, requestConfig) => { + // This is not documented yet required + // ref. https://forum.mattermost.com/t/solved-invalid-or-expired-session-please-login-again/6772 + requestConfig.headers = requestConfig.headers || {}; + requestConfig.headers['X-Requested-With'] = `XMLHttpRequest`; + return requestConfig; +}; + +export default addXRequestedWithHeader; diff --git a/packages/backend/src/apps/mattermost/common/get-base-url.js b/packages/backend/src/apps/mattermost/common/get-base-url.js new file mode 100644 index 0000000..8ba13c4 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/get-base-url.js @@ -0,0 +1,5 @@ +const getBaseUrl = ($) => { + return $.auth.data.instanceUrl; +}; + +export default getBaseUrl; diff --git a/packages/backend/src/apps/mattermost/common/get-current-user.js b/packages/backend/src/apps/mattermost/common/get-current-user.js new file mode 100644 index 0000000..84286c2 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/get-current-user.js @@ -0,0 +1,7 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/api/v4/users/me'); + const currentUser = response.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/mattermost/common/set-base-url.js b/packages/backend/src/apps/mattermost/common/set-base-url.js new file mode 100644 index 0000000..af904f7 --- /dev/null +++ b/packages/backend/src/apps/mattermost/common/set-base-url.js @@ -0,0 +1,7 @@ +const setBaseUrl = ($, requestConfig) => { + requestConfig.baseURL = $.auth.data.instanceUrl; + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/mattermost/dynamic-data/index.js b/packages/backend/src/apps/mattermost/dynamic-data/index.js new file mode 100644 index 0000000..4305d61 --- /dev/null +++ b/packages/backend/src/apps/mattermost/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listChannels from './list-channels/index.js'; + +export default [listChannels]; diff --git a/packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.js b/packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.js new file mode 100644 index 0000000..ab904c9 --- /dev/null +++ b/packages/backend/src/apps/mattermost/dynamic-data/list-channels/index.js @@ -0,0 +1,22 @@ +export default { + name: 'List channels', + key: 'listChannels', + + async run($) { + const channels = { + data: [], + error: null, + }; + + const response = await $.http.get('/api/v4/users/me/channels'); // this endpoint will return only channels user joined, there is no endpoint to list all channels available for user + + for (const channel of response.data) { + channels.data.push({ + value: channel.id, + name: channel.display_name || channel.id, // it's possible for channel to not have any name thus falling back to using id + }); + } + + return channels; + }, +}; diff --git a/packages/backend/src/apps/mattermost/index.js b/packages/backend/src/apps/mattermost/index.js new file mode 100644 index 0000000..c7efe74 --- /dev/null +++ b/packages/backend/src/apps/mattermost/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import addXRequestedWithHeader from './common/add-x-requested-with-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Mattermost', + key: 'mattermost', + iconUrl: '{BASE_URL}/apps/mattermost/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/mattermost/connection', + baseUrl: 'https://mattermost.com', + apiBaseUrl: '', // there is no cloud version of this app, user always need to provide address of own instance when creating connection + primaryColor: '#4a154b', + supportsConnections: true, + beforeRequest: [setBaseUrl, addXRequestedWithHeader, addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/miro/actions/copy-board/index.js b/packages/backend/src/apps/miro/actions/copy-board/index.js new file mode 100644 index 0000000..201a425 --- /dev/null +++ b/packages/backend/src/apps/miro/actions/copy-board/index.js @@ -0,0 +1,116 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Copy board', + key: 'copyBoard', + description: 'Creates a copy of an existing board.', + arguments: [ + { + label: 'Original board', + key: 'originalBoard', + type: 'dropdown', + required: true, + description: 'The board that you want to copy.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBoards', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: 'Title for the board.', + variables: true, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + description: 'Description of the board.', + variables: true, + }, + { + label: 'Team Access', + key: 'teamAccess', + type: 'dropdown', + required: false, + description: + 'Team access to the board. Can be private, view, comment or edit. Default: private.', + variables: true, + options: [ + { + label: 'Private - nobody in the team can find and access the board', + value: 'private', + }, + { + label: 'View - any team member can find and view the board', + value: 'view', + }, + { + label: 'Comment - any team member can find and comment the board', + value: 'comment', + }, + { + label: 'Edit - any team member can find and edit the board', + value: 'edit', + }, + ], + }, + { + label: 'Access Via Link', + key: 'accessViaLink', + type: 'dropdown', + required: false, + description: + 'Access to the board by link. Can be private, view, comment. Default: private.', + variables: true, + options: [ + { + label: 'Private - only you have access to the board', + value: 'private', + }, + { + label: 'View - can view, no sign-in required', + value: 'view', + }, + { + label: 'Comment - can comment, no sign-in required', + value: 'comment', + }, + ], + }, + ], + + async run($) { + const params = { + copy_from: $.step.parameters.originalBoard, + }; + + const body = { + name: $.step.parameters.title, + description: $.step.parameters.description, + policy: { + sharingPolicy: { + access: $.step.parameters.accessViaLink || 'private', + teamAccess: $.step.parameters.teamAccess || 'private', + }, + }, + }; + + const { data } = await $.http.put('/v2/boards', body, { params }); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/miro/actions/create-board/index.js b/packages/backend/src/apps/miro/actions/create-board/index.js new file mode 100644 index 0000000..6633c6d --- /dev/null +++ b/packages/backend/src/apps/miro/actions/create-board/index.js @@ -0,0 +1,94 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create board', + key: 'createBoard', + description: 'Creates a new board.', + arguments: [ + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: 'Title for the board.', + variables: true, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + description: 'Description of the board.', + variables: true, + }, + { + label: 'Team Access', + key: 'teamAccess', + type: 'dropdown', + required: false, + description: + 'Team access to the board. Can be private, view, comment or edit. Default: private.', + variables: true, + options: [ + { + label: 'Private - nobody in the team can find and access the board', + value: 'private', + }, + { + label: 'View - any team member can find and view the board', + value: 'view', + }, + { + label: 'Comment - any team member can find and comment the board', + value: 'comment', + }, + { + label: 'Edit - any team member can find and edit the board', + value: 'edit', + }, + ], + }, + { + label: 'Access Via Link', + key: 'accessViaLink', + type: 'dropdown', + required: false, + description: + 'Access to the board by link. Can be private, view, comment. Default: private.', + variables: true, + options: [ + { + label: 'Private - only you have access to the board', + value: 'private', + }, + { + label: 'View - can view, no sign-in required', + value: 'view', + }, + { + label: 'Comment - can comment, no sign-in required', + value: 'comment', + }, + ], + }, + ], + + async run($) { + const body = { + name: $.step.parameters.title, + description: $.step.parameters.description, + policy: { + sharingPolicy: { + access: $.step.parameters.accessViaLink || 'private', + teamAccess: $.step.parameters.teamAccess || 'private', + }, + }, + }; + + const { data } = await $.http.post('/v2/boards', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/miro/actions/create-card-widget/index.js b/packages/backend/src/apps/miro/actions/create-card-widget/index.js new file mode 100644 index 0000000..b319694 --- /dev/null +++ b/packages/backend/src/apps/miro/actions/create-card-widget/index.js @@ -0,0 +1,154 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create card widget', + key: 'createCardWidget', + description: 'Creates a new card widget on an existing board.', + arguments: [ + { + label: 'Board', + key: 'boardId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBoards', + }, + ], + }, + }, + { + label: 'Frame', + key: 'frameId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.boardId'], + description: + 'You need to create a frame prior to this step. Switch frame to grid mode to avoid cards overlap.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFrames', + }, + { + name: 'parameters.boardId', + value: '{parameters.boardId}', + }, + ], + }, + }, + { + label: 'Card Title', + key: 'cardTitle', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Card Title Link', + key: 'cardTitleLink', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Card Description', + key: 'cardDescription', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Card Due Date', + key: 'cardDueDate', + type: 'string', + required: false, + description: + 'format: date-time. Example value: 2023-10-12 22:00:55+00:00', + variables: true, + }, + { + label: 'Card Border Color', + key: 'cardBorderColor', + type: 'dropdown', + required: false, + description: 'In hex format. Default is blue (#2399F3).', + variables: true, + options: [ + { label: 'white', value: '#FFFFFF' }, + { label: 'yellow', value: '#FEF445' }, + { label: 'orange', value: '#FAC710' }, + { label: 'red', value: '#F24726' }, + { label: 'bright red', value: '#DA0063' }, + { label: 'light gray', value: '#E6E6E6' }, + { label: 'gray', value: '#808080' }, + { label: 'black', value: '#1A1A1A' }, + { label: 'light green', value: '#CEE741' }, + { label: 'green', value: '#8FD14F' }, + { label: 'dark green', value: '#0CA789' }, + { label: 'light blue', value: '#12CDD4' }, + { label: 'blue', value: '#2D9BF0' }, + { label: 'dark blue', value: '#414BB2' }, + { label: 'purple', value: '#9510AC' }, + { label: 'dark purple', value: '#652CB3' }, + ], + }, + ], + + async run($) { + const { + boardId, + frameId, + cardTitle, + cardTitleLink, + cardDescription, + cardDueDate, + cardBorderColor, + } = $.step.parameters; + + let title; + if (cardTitleLink) { + title = `${cardTitle}`; + } else { + title = cardTitle; + } + + const body = { + data: { + title: title, + description: cardDescription, + }, + style: {}, + parent: { + id: frameId, + }, + }; + + if (cardBorderColor) { + body.style.cardTheme = cardBorderColor; + } + + if (cardDueDate) { + body.data.dueDate = cardDueDate; + } + + const response = await $.http.post(`/v2/boards/${boardId}/cards`, body); + + $.setActionItem({ + raw: response.data, + }); + }, +}); diff --git a/packages/backend/src/apps/miro/actions/index.js b/packages/backend/src/apps/miro/actions/index.js new file mode 100644 index 0000000..c200553 --- /dev/null +++ b/packages/backend/src/apps/miro/actions/index.js @@ -0,0 +1,5 @@ +import copyBoard from './copy-board/index.js'; +import createBoard from './create-board/index.js'; +import createCardWidget from './create-card-widget/index.js'; + +export default [copyBoard, createBoard, createCardWidget]; diff --git a/packages/backend/src/apps/miro/assets/favicon.svg b/packages/backend/src/apps/miro/assets/favicon.svg new file mode 100644 index 0000000..b87ea9a --- /dev/null +++ b/packages/backend/src/apps/miro/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/miro/auth/generate-auth-url.js b/packages/backend/src/apps/miro/auth/generate-auth-url.js new file mode 100644 index 0000000..cb70ad3 --- /dev/null +++ b/packages/backend/src/apps/miro/auth/generate-auth-url.js @@ -0,0 +1,19 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + response_type: 'code', + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + }); + + const url = `https://miro.com/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/miro/auth/index.js b/packages/backend/src/apps/miro/auth/index.js new file mode 100644 index 0000000..e971a70 --- /dev/null +++ b/packages/backend/src/apps/miro/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/miro/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Miro, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/miro/auth/is-still-verified.js b/packages/backend/src/apps/miro/auth/is-still-verified.js new file mode 100644 index 0000000..6d792b1 --- /dev/null +++ b/packages/backend/src/apps/miro/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/miro/auth/refresh-token.js b/packages/backend/src/apps/miro/auth/refresh-token.js new file mode 100644 index 0000000..62be5d2 --- /dev/null +++ b/packages/backend/src/apps/miro/auth/refresh-token.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'node:url'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post('/v1/oauth/token', params.toString()); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + scope: data.scope, + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/miro/auth/verify-credentials.js b/packages/backend/src/apps/miro/auth/verify-credentials.js new file mode 100644 index 0000000..d6d862c --- /dev/null +++ b/packages/backend/src/apps/miro/auth/verify-credentials.js @@ -0,0 +1,39 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = { + grant_type: 'authorization_code', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + redirect_uri: redirectUri, + }; + + const { data } = await $.http.post(`/v1/oauth/token`, null, { + params, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + userId: data.user_id, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + teamId: data.team_id, + scope: data.scope, + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + screenName: currentUser.name, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/miro/common/add-auth-header.js b/packages/backend/src/apps/miro/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/miro/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/miro/common/get-current-user.js b/packages/backend/src/apps/miro/common/get-current-user.js new file mode 100644 index 0000000..2c09552 --- /dev/null +++ b/packages/backend/src/apps/miro/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data } = await $.http.get( + `https://api.miro.com/v1/oauth-token?access_token=${$.auth.data.accessToken}` + ); + return data.user; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/miro/dynamic-data/index.js b/packages/backend/src/apps/miro/dynamic-data/index.js new file mode 100644 index 0000000..fd8026f --- /dev/null +++ b/packages/backend/src/apps/miro/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listBoards from './list-boards/index.js'; +import listFrames from './list-frames/index.js'; + +export default [listBoards, listFrames]; diff --git a/packages/backend/src/apps/miro/dynamic-data/list-boards/index.js b/packages/backend/src/apps/miro/dynamic-data/list-boards/index.js new file mode 100644 index 0000000..849ebc5 --- /dev/null +++ b/packages/backend/src/apps/miro/dynamic-data/list-boards/index.js @@ -0,0 +1,30 @@ +export default { + name: 'List boards', + key: 'listBoards', + + async run($) { + const boards = { + data: [], + }; + + let next; + do { + const { + data: { data, links }, + } = await $.http.get('/v2/boards'); + + next = links?.next; + + if (data.length) { + for (const board of data) { + boards.data.push({ + value: board.id, + name: board.name, + }); + } + } + } while (next); + + return boards; + }, +}; diff --git a/packages/backend/src/apps/miro/dynamic-data/list-frames/index.js b/packages/backend/src/apps/miro/dynamic-data/list-frames/index.js new file mode 100644 index 0000000..2cbafd3 --- /dev/null +++ b/packages/backend/src/apps/miro/dynamic-data/list-frames/index.js @@ -0,0 +1,38 @@ +export default { + name: 'List frames', + key: 'listFrames', + + async run($) { + const frames = { + data: [], + }; + + const boardId = $.step.parameters.boardId; + + if (!boardId) { + return { data: [] }; + } + + let next; + do { + const { + data: { data, links }, + } = await $.http.get(`/v2/boards/${boardId}/items`); + + next = links?.next; + + const allFrames = data.filter((item) => item.type === 'frame'); + + if (allFrames.length) { + for (const frame of allFrames) { + frames.data.push({ + value: frame.id, + name: frame.data.title, + }); + } + } + } while (next); + + return frames; + }, +}; diff --git a/packages/backend/src/apps/miro/index.js b/packages/backend/src/apps/miro/index.js new file mode 100644 index 0000000..878d255 --- /dev/null +++ b/packages/backend/src/apps/miro/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Miro', + key: 'miro', + baseUrl: 'https://miro.com', + apiBaseUrl: 'https://api.miro.com', + iconUrl: '{BASE_URL}/apps/miro/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/miro/connection', + primaryColor: '#F2CA02', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js b/packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js new file mode 100644 index 0000000..8e98310 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/actions/create-chat-completion/index.js @@ -0,0 +1,157 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create chat completion', + key: 'createChatCompletion', + description: 'Creates a chat completion.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: + 'The prompt(s) to generate completions for, encoded as a list of dict with role and content.', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: `The maximum number of tokens to generate in the completion. The token count of your prompt plus max_tokens cannot exceed the model's context length.`, + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: 'Stop generation if one of these tokens is detected', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'Nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Frequency_penalty penalizes the repetition of words based on their frequency in the generated text. A higher frequency penalty discourages the model from repeating words that have already appeared frequently in the output, promoting diversity and reducing repetition.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Presence penalty determines how much the model penalizes the repetition of words or phrases. A higher presence penalty encourages the model to use a wider variety of words and phrases, making the output more diverse and creative.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const messages = $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + const payload = { + model: $.step.parameters.model, + messages, + stop: nonEmptyStopSequences, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/mistral-ai/actions/index.js b/packages/backend/src/apps/mistral-ai/actions/index.js new file mode 100644 index 0000000..cc0e053 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/actions/index.js @@ -0,0 +1,3 @@ +import createChatCompletion from './create-chat-completion/index.js'; + +export default [createChatCompletion]; diff --git a/packages/backend/src/apps/mistral-ai/assets/favicon.svg b/packages/backend/src/apps/mistral-ai/assets/favicon.svg new file mode 100644 index 0000000..3f58330 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/assets/favicon.svg @@ -0,0 +1,32 @@ + + + Mistral AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/mistral-ai/auth/index.js b/packages/backend/src/apps/mistral-ai/auth/index.js new file mode 100644 index 0000000..429699e --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Mistral AI API key of your account.', + docUrl: 'https://automatisch.io/docs/mistral-ai#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/mistral-ai/auth/is-still-verified.js b/packages/backend/src/apps/mistral-ai/auth/is-still-verified.js new file mode 100644 index 0000000..3e6c909 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/mistral-ai/auth/verify-credentials.js b/packages/backend/src/apps/mistral-ai/auth/verify-credentials.js new file mode 100644 index 0000000..7f43f88 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/mistral-ai/common/add-auth-header.js b/packages/backend/src/apps/mistral-ai/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/mistral-ai/dynamic-data/index.js b/packages/backend/src/apps/mistral-ai/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js b/packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js new file mode 100644 index 0000000..a8e8153 --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const response = await $.http.get('/v1/models'); + + const models = response.data.data.map((model) => { + return { + value: model.id, + name: model.id, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/mistral-ai/index.js b/packages/backend/src/apps/mistral-ai/index.js new file mode 100644 index 0000000..08f9e4b --- /dev/null +++ b/packages/backend/src/apps/mistral-ai/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Mistral AI', + key: 'mistral-ai', + baseUrl: 'https://mistral.ai', + apiBaseUrl: 'https://api.mistral.ai', + iconUrl: '{BASE_URL}/apps/mistral-ai/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/mistral-ai/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/notion/actions/create-database-item/index.js b/packages/backend/src/apps/notion/actions/create-database-item/index.js new file mode 100644 index 0000000..c9c4a4e --- /dev/null +++ b/packages/backend/src/apps/notion/actions/create-database-item/index.js @@ -0,0 +1,93 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create database item', + key: 'createDatabaseItem', + description: 'Creates an item in a database.', + arguments: [ + { + label: 'Database', + key: 'databaseId', + type: 'dropdown', + required: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDatabases', + }, + ], + }, + }, + { + label: 'Name', + key: 'name', + type: 'string', + required: false, + description: + 'This field has a 2000 character limit. Any characters beyond 2000 will not be included.', + variables: true, + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: false, + description: + 'The text to add to the page body. The max length for this field is 2000 characters. Any characters beyond 2000 will not be included.', + variables: true, + }, + ], + + async run($) { + const name = $.step.parameters.name; + const truncatedName = name.slice(0, 2000); + const content = $.step.parameters.content; + const truncatedContent = content.slice(0, 2000); + + const body = { + parent: { + database_id: $.step.parameters.databaseId, + }, + properties: {}, + children: [], + }; + + if (name) { + body.properties.Name = { + title: [ + { + text: { + content: truncatedName, + }, + }, + ], + }; + } + + if (content) { + body.children = [ + { + object: 'block', + paragraph: { + rich_text: [ + { + text: { + content: truncatedContent, + }, + }, + ], + }, + }, + ]; + } + + const { data } = await $.http.post('/v1/pages', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/notion/actions/create-page/index.js b/packages/backend/src/apps/notion/actions/create-page/index.js new file mode 100644 index 0000000..3754630 --- /dev/null +++ b/packages/backend/src/apps/notion/actions/create-page/index.js @@ -0,0 +1,98 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create page', + key: 'createPage', + description: 'Creates a page inside a parent page', + arguments: [ + { + label: 'Parent page', + key: 'parentPageId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listParentPages', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: false, + description: + 'This field has a 2000 character limit. Any characters beyond 2000 will not be included.', + variables: true, + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: false, + description: + 'The text to add to the page body. The max length for this field is 2000 characters. Any characters beyond 2000 will not be included.', + variables: true, + }, + ], + + async run($) { + const parentPageId = $.step.parameters.parentPageId; + const title = $.step.parameters.title; + const truncatedTitle = title.slice(0, 2000); + const content = $.step.parameters.content; + const truncatedContent = content.slice(0, 2000); + + const body = { + parent: { + page_id: parentPageId, + }, + properties: {}, + children: [], + }; + + if (title) { + body.properties.title = { + type: 'title', + title: [ + { + text: { + content: truncatedTitle, + }, + }, + ], + }; + } + + if (content) { + body.children = [ + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { + content: truncatedContent, + }, + }, + ], + }, + }, + ]; + } + + const { data } = await $.http.post('/v1/pages', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/notion/actions/find-database-item/index.js b/packages/backend/src/apps/notion/actions/find-database-item/index.js new file mode 100644 index 0000000..44f9fea --- /dev/null +++ b/packages/backend/src/apps/notion/actions/find-database-item/index.js @@ -0,0 +1,64 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find database item', + key: 'findDatabaseItem', + description: 'Searches for an item in a database by property.', + arguments: [ + { + label: 'Database', + key: 'databaseId', + type: 'dropdown', + required: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDatabases', + }, + ], + }, + }, + { + label: 'Name', + key: 'name', + type: 'string', + required: false, + description: + 'This field has a 2000 character limit. Any characters beyond 2000 will not be included.', + variables: true, + }, + ], + + async run($) { + const databaseId = $.step.parameters.databaseId; + const name = $.step.parameters.name; + const truncatedName = name.slice(0, 2000); + + const body = { + filter: { + property: 'Name', + rich_text: { + equals: truncatedName, + }, + }, + sorts: [ + { + timestamp: 'last_edited_time', + direction: 'descending', + }, + ], + }; + + const { data } = await $.http.post( + `/v1/databases/${databaseId}/query`, + body + ); + + $.setActionItem({ + raw: data.results[0], + }); + }, +}); diff --git a/packages/backend/src/apps/notion/actions/index.js b/packages/backend/src/apps/notion/actions/index.js new file mode 100644 index 0000000..0b1629e --- /dev/null +++ b/packages/backend/src/apps/notion/actions/index.js @@ -0,0 +1,5 @@ +import createDatabaseItem from './create-database-item/index.js'; +import createPage from './create-page/index.js'; +import findDatabaseItem from './find-database-item/index.js'; + +export default [createDatabaseItem, createPage, findDatabaseItem]; diff --git a/packages/backend/src/apps/notion/assets/favicon.svg b/packages/backend/src/apps/notion/assets/favicon.svg new file mode 100644 index 0000000..ebcbe81 --- /dev/null +++ b/packages/backend/src/apps/notion/assets/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/backend/src/apps/notion/auth/generate-auth-url.js b/packages/backend/src/apps/notion/auth/generate-auth-url.js new file mode 100644 index 0000000..5547ec1 --- /dev/null +++ b/packages/backend/src/apps/notion/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URL, URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + response_type: 'code', + owner: 'user', + }); + + const url = new URL( + `/v1/oauth/authorize?${searchParams}`, + $.app.apiBaseUrl + ).toString(); + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/notion/auth/index.js b/packages/backend/src/apps/notion/auth/index.js new file mode 100644 index 0000000..5dba750 --- /dev/null +++ b/packages/backend/src/apps/notion/auth/index.js @@ -0,0 +1,49 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/notion/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Notion OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/notion#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/notion#client-id', + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/notion#client-secret', + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/notion/auth/is-still-verified.js b/packages/backend/src/apps/notion/auth/is-still-verified.js new file mode 100644 index 0000000..1b9d46d --- /dev/null +++ b/packages/backend/src/apps/notion/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/notion/auth/verify-credentials.js b/packages/backend/src/apps/notion/auth/verify-credentials.js new file mode 100644 index 0000000..5bed76f --- /dev/null +++ b/packages/backend/src/apps/notion/auth/verify-credentials.js @@ -0,0 +1,52 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const response = await $.http.post( + `${$.app.apiBaseUrl}/v1/oauth/token`, + { + redirect_uri: redirectUri, + code: $.auth.data.code, + grant_type: 'authorization_code', + }, + { + headers: { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + }, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + const data = response.data; + + $.auth.data.accessToken = data.access_token; + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + accessToken: data.access_token, + botId: data.bot_id, + duplicatedTemplateId: data.duplicated_template_id, + owner: data.owner, + tokenType: data.token_type, + workspaceIcon: data.workspace_icon, + workspaceId: data.workspace_id, + workspaceName: data.workspace_name, + screenName: data.workspace_name, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + screenName: `${currentUser.name} @ ${data.workspace_name}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/notion/common/add-auth-header.js b/packages/backend/src/apps/notion/common/add-auth-header.js new file mode 100644 index 0000000..38e6909 --- /dev/null +++ b/packages/backend/src/apps/notion/common/add-auth-header.js @@ -0,0 +1,13 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.additionalProperties?.skipAddingAuthHeader) + return requestConfig; + + if ($.auth.data?.accessToken) { + const authorizationHeader = `Bearer ${$.auth.data.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/notion/common/add-notion-version-header.js b/packages/backend/src/apps/notion/common/add-notion-version-header.js new file mode 100644 index 0000000..08b2f90 --- /dev/null +++ b/packages/backend/src/apps/notion/common/add-notion-version-header.js @@ -0,0 +1,7 @@ +const addNotionVersionHeader = ($, requestConfig) => { + requestConfig.headers['Notion-Version'] = '2022-06-28'; + + return requestConfig; +}; + +export default addNotionVersionHeader; diff --git a/packages/backend/src/apps/notion/common/get-current-user.js b/packages/backend/src/apps/notion/common/get-current-user.js new file mode 100644 index 0000000..8147d1f --- /dev/null +++ b/packages/backend/src/apps/notion/common/get-current-user.js @@ -0,0 +1,9 @@ +const getCurrentUser = async ($) => { + const userId = $.auth.data.owner.user.id; + const response = await $.http.get(`/v1/users/${userId}`); + + const currentUser = response.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/notion/dynamic-data/index.js b/packages/backend/src/apps/notion/dynamic-data/index.js new file mode 100644 index 0000000..3811a7c --- /dev/null +++ b/packages/backend/src/apps/notion/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listDatabases from './list-databases/index.js'; +import listParentPages from './list-parent-pages/index.js'; + +export default [listDatabases, listParentPages]; diff --git a/packages/backend/src/apps/notion/dynamic-data/list-databases/index.js b/packages/backend/src/apps/notion/dynamic-data/list-databases/index.js new file mode 100644 index 0000000..da8f27d --- /dev/null +++ b/packages/backend/src/apps/notion/dynamic-data/list-databases/index.js @@ -0,0 +1,32 @@ +export default { + name: 'List databases', + key: 'listDatabases', + + async run($) { + const databases = { + data: [], + error: null, + }; + const payload = { + filter: { + value: 'database', + property: 'object', + }, + }; + + do { + const response = await $.http.post('/v1/search', payload); + + payload.start_cursor = response.data.next_cursor; + + for (const database of response.data.results) { + databases.data.push({ + value: database.id, + name: database.title[0].plain_text, + }); + } + } while (payload.start_cursor); + + return databases; + }, +}; diff --git a/packages/backend/src/apps/notion/dynamic-data/list-parent-pages/index.js b/packages/backend/src/apps/notion/dynamic-data/list-parent-pages/index.js new file mode 100644 index 0000000..9212e9b --- /dev/null +++ b/packages/backend/src/apps/notion/dynamic-data/list-parent-pages/index.js @@ -0,0 +1,36 @@ +export default { + name: 'List parent pages', + key: 'listParentPages', + + async run($) { + const parentPages = { + data: [], + error: null, + }; + const payload = { + filter: { + value: 'page', + property: 'object', + }, + }; + + do { + const response = await $.http.post('/v1/search', payload); + + payload.start_cursor = response.data.next_cursor; + + const topLevelPages = response.data.results.filter( + (page) => page.parent.workspace + ); + + for (const pages of topLevelPages) { + parentPages.data.push({ + value: pages.id, + name: pages.properties.title.title[0].plain_text, + }); + } + } while (payload.start_cursor); + + return parentPages; + }, +}; diff --git a/packages/backend/src/apps/notion/index.js b/packages/backend/src/apps/notion/index.js new file mode 100644 index 0000000..cab7f12 --- /dev/null +++ b/packages/backend/src/apps/notion/index.js @@ -0,0 +1,23 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import addNotionVersionHeader from './common/add-notion-version-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Notion', + key: 'notion', + baseUrl: 'https://notion.com', + apiBaseUrl: 'https://api.notion.com', + iconUrl: '{BASE_URL}/apps/notion/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/notion/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader, addNotionVersionHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/notion/triggers/index.js b/packages/backend/src/apps/notion/triggers/index.js new file mode 100644 index 0000000..85d3380 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/index.js @@ -0,0 +1,4 @@ +import newDatabaseItems from './new-database-items/index.js'; +import updatedDatabaseItems from './updated-database-items/index.js'; + +export default [newDatabaseItems, updatedDatabaseItems]; diff --git a/packages/backend/src/apps/notion/triggers/new-database-items/index.js b/packages/backend/src/apps/notion/triggers/new-database-items/index.js new file mode 100644 index 0000000..8ed8ccd --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/new-database-items/index.js @@ -0,0 +1,32 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newDatabaseItems from './new-database-items.js'; + +export default defineTrigger({ + name: 'New database items', + key: 'newDatabaseItems', + pollInterval: 15, + description: 'Triggers when a new database item is created', + arguments: [ + { + label: 'Database', + key: 'databaseId', + type: 'dropdown', + required: false, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDatabases', + }, + ], + }, + }, + ], + + async run($) { + await newDatabaseItems($); + }, +}); diff --git a/packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.js b/packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.js new file mode 100644 index 0000000..1d134f5 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/new-database-items/new-database-items.js @@ -0,0 +1,29 @@ +const newDatabaseItems = async ($) => { + const payload = { + sorts: [ + { + timestamp: 'created_time', + direction: 'descending', + }, + ], + }; + + const databaseId = $.step.parameters.databaseId; + const path = `/v1/databases/${databaseId}/query`; + do { + const response = await $.http.post(path, payload); + + payload.start_cursor = response.data.next_cursor; + + for (const databaseItem of response.data.results) { + $.pushTriggerItem({ + raw: databaseItem, + meta: { + internalId: databaseItem.id, + }, + }); + } + } while (payload.start_cursor); +}; + +export default newDatabaseItems; diff --git a/packages/backend/src/apps/notion/triggers/updated-database-items/index.js b/packages/backend/src/apps/notion/triggers/updated-database-items/index.js new file mode 100644 index 0000000..a08ced6 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/updated-database-items/index.js @@ -0,0 +1,33 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import updatedDatabaseItems from './updated-database-items.js'; + +export default defineTrigger({ + name: 'Updated database items', + key: 'updatedDatabaseItems', + pollInterval: 15, + description: + 'Triggers when there is an update to an item in a chosen database', + arguments: [ + { + label: 'Database', + key: 'databaseId', + type: 'dropdown', + required: false, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDatabases', + }, + ], + }, + }, + ], + + async run($) { + await updatedDatabaseItems($); + }, +}); diff --git a/packages/backend/src/apps/notion/triggers/updated-database-items/updated-database-items.js b/packages/backend/src/apps/notion/triggers/updated-database-items/updated-database-items.js new file mode 100644 index 0000000..282aaf9 --- /dev/null +++ b/packages/backend/src/apps/notion/triggers/updated-database-items/updated-database-items.js @@ -0,0 +1,29 @@ +const updatedDatabaseItems = async ($) => { + const payload = { + sorts: [ + { + timestamp: 'last_edited_time', + direction: 'descending', + }, + ], + }; + + const databaseId = $.step.parameters.databaseId; + const path = `/v1/databases/${databaseId}/query`; + do { + const response = await $.http.post(path, payload); + + payload.start_cursor = response.data.next_cursor; + + for (const databaseItem of response.data.results) { + $.pushTriggerItem({ + raw: databaseItem, + meta: { + internalId: `${databaseItem.id}-${databaseItem.last_edited_time}`, + }, + }); + } + } while (payload.start_cursor); +}; + +export default updatedDatabaseItems; diff --git a/packages/backend/src/apps/ntfy/actions/index.js b/packages/backend/src/apps/ntfy/actions/index.js new file mode 100644 index 0000000..92d67c2 --- /dev/null +++ b/packages/backend/src/apps/ntfy/actions/index.js @@ -0,0 +1,3 @@ +import sendMessage from './send-message/index.js'; + +export default [sendMessage]; diff --git a/packages/backend/src/apps/ntfy/actions/send-message/index.js b/packages/backend/src/apps/ntfy/actions/send-message/index.js new file mode 100644 index 0000000..a25db4a --- /dev/null +++ b/packages/backend/src/apps/ntfy/actions/send-message/index.js @@ -0,0 +1,96 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send message', + key: 'sendMessage', + description: 'Sends a message to a topic you specify.', + arguments: [ + { + label: 'Topic', + key: 'topic', + type: 'string', + required: true, + description: 'Target topic name.', + variables: true, + }, + { + label: 'Message body', + key: 'message', + type: 'string', + required: true, + description: + 'Message body to be sent, set to triggered if empty or not passed.', + variables: true, + }, + { + label: 'Title', + key: 'title', + type: 'string', + required: false, + description: 'Message title.', + variables: true, + }, + { + label: 'Email', + key: 'email', + type: 'string', + required: false, + description: 'E-mail address for e-mail notifications.', + variables: true, + }, + { + label: 'Click URL', + key: 'click', + type: 'string', + required: false, + description: 'Website opened when notification is clicked.', + variables: true, + }, + { + label: 'Attach file by URL', + key: 'attach', + type: 'string', + required: false, + description: 'URL of an attachment.', + variables: true, + }, + { + label: 'Filename', + key: 'filename', + type: 'string', + required: false, + description: 'File name of the attachment.', + variables: true, + }, + { + label: 'Delay', + key: 'delay', + type: 'string', + required: false, + description: + 'Timestamp or duration for delayed delivery. For example, 30min or 9am.', + variables: true, + }, + ], + + async run($) { + const { topic, message, title, email, click, attach, filename, delay } = + $.step.parameters; + const payload = { + topic, + message, + title, + email, + click, + attach, + filename, + delay, + }; + + const response = await $.http.post('/', payload); + + $.setActionItem({ + raw: response.data, + }); + }, +}); diff --git a/packages/backend/src/apps/ntfy/assets/favicon.svg b/packages/backend/src/apps/ntfy/assets/favicon.svg new file mode 100644 index 0000000..9e5b513 --- /dev/null +++ b/packages/backend/src/apps/ntfy/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/ntfy/auth/index.js b/packages/backend/src/apps/ntfy/auth/index.js new file mode 100644 index 0000000..f8eaed3 --- /dev/null +++ b/packages/backend/src/apps/ntfy/auth/index.js @@ -0,0 +1,43 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'serverUrl', + label: 'Server URL', + type: 'string', + required: true, + readOnly: false, + value: 'https://ntfy.sh', + placeholder: null, + description: 'ntfy server to use.', + clickToCopy: false, + }, + { + key: 'username', + label: 'Username', + type: 'string', + required: false, + readOnly: false, + placeholder: null, + clickToCopy: false, + description: + 'You may need to provide your username if your installation requires authentication.', + }, + { + key: 'password', + label: 'Password', + type: 'string', + required: false, + readOnly: false, + placeholder: null, + clickToCopy: false, + description: + 'You may need to provide your password if your installation requires authentication.', + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/ntfy/auth/is-still-verified.js b/packages/backend/src/apps/ntfy/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/ntfy/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/ntfy/auth/verify-credentials.js b/packages/backend/src/apps/ntfy/auth/verify-credentials.js new file mode 100644 index 0000000..4a1aed2 --- /dev/null +++ b/packages/backend/src/apps/ntfy/auth/verify-credentials.js @@ -0,0 +1,14 @@ +const verifyCredentials = async ($) => { + await $.http.post('/', { topic: 'automatisch' }); + let screenName = $.auth.data.serverUrl; + + if ($.auth.data.username) { + screenName = `${$.auth.data.username} @ ${screenName}`; + } + + await $.auth.set({ + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/ntfy/common/add-auth-header.js b/packages/backend/src/apps/ntfy/common/add-auth-header.js new file mode 100644 index 0000000..0c53368 --- /dev/null +++ b/packages/backend/src/apps/ntfy/common/add-auth-header.js @@ -0,0 +1,16 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data.serverUrl) { + requestConfig.baseURL = $.auth.data.serverUrl; + } + + if ($.auth.data?.username && $.auth.data?.password) { + requestConfig.auth = { + username: $.auth.data.username, + password: $.auth.data.password, + }; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/ntfy/index.js b/packages/backend/src/apps/ntfy/index.js new file mode 100644 index 0000000..0b19ea7 --- /dev/null +++ b/packages/backend/src/apps/ntfy/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Ntfy', + key: 'ntfy', + iconUrl: '{BASE_URL}/apps/ntfy/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/ntfy/connection', + supportsConnections: true, + baseUrl: 'https://ntfy.sh', + apiBaseUrl: 'https://ntfy.sh', + primaryColor: '#56bda8', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/odoo/actions/create-lead/index.js b/packages/backend/src/apps/odoo/actions/create-lead/index.js new file mode 100644 index 0000000..1e0861d --- /dev/null +++ b/packages/backend/src/apps/odoo/actions/create-lead/index.js @@ -0,0 +1,98 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { authenticate, asyncMethodCall } from '../../common/xmlrpc-client.js'; + +export default defineAction({ + name: 'Create Lead', + key: 'createLead', + description: '', + arguments: [ + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + description: 'Lead name', + variables: true, + }, + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: true, + variables: true, + options: [ + { + label: 'Lead', + value: 'lead', + }, + { + label: 'Opportunity', + value: 'opportunity', + }, + ], + }, + { + label: 'Email', + key: 'email', + type: 'string', + required: false, + description: 'Email of lead contact', + variables: true, + }, + { + label: 'Contact Name', + key: 'contactName', + type: 'string', + required: false, + description: 'Name of lead contact', + variables: true, + }, + { + label: 'Phone Number', + key: 'phoneNumber', + type: 'string', + required: false, + description: 'Phone number of lead contact', + variables: true, + }, + { + label: 'Mobile Number', + key: 'mobileNumber', + type: 'string', + required: false, + description: 'Mobile number of lead contact', + variables: true, + }, + ], + + async run($) { + const uid = await authenticate($); + const id = await asyncMethodCall($, { + method: 'execute_kw', + params: [ + $.auth.data.databaseName, + uid, + $.auth.data.apiKey, + 'crm.lead', + 'create', + [ + { + name: $.step.parameters.name, + type: $.step.parameters.type, + email_from: $.step.parameters.email, + contact_name: $.step.parameters.contactName, + phone: $.step.parameters.phoneNumber, + mobile: $.step.parameters.mobileNumber, + }, + ], + ], + path: 'object', + }); + + $.setActionItem({ + raw: { + id: id, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/odoo/actions/index.js b/packages/backend/src/apps/odoo/actions/index.js new file mode 100644 index 0000000..5fb0747 --- /dev/null +++ b/packages/backend/src/apps/odoo/actions/index.js @@ -0,0 +1,3 @@ +import createLead from './create-lead/index.js'; + +export default [createLead]; diff --git a/packages/backend/src/apps/odoo/assets/favicon.svg b/packages/backend/src/apps/odoo/assets/favicon.svg new file mode 100644 index 0000000..aeb5dd7 --- /dev/null +++ b/packages/backend/src/apps/odoo/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/odoo/auth/index.js b/packages/backend/src/apps/odoo/auth/index.js new file mode 100644 index 0000000..5536663 --- /dev/null +++ b/packages/backend/src/apps/odoo/auth/index.js @@ -0,0 +1,88 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'host', + label: 'Host Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Host name of your Odoo Server (e.g. sub.domain.com without the protocol)', + clickToCopy: false, + }, + { + key: 'port', + label: 'Port', + type: 'string', + required: true, + readOnly: false, + value: '443', + placeholder: null, + description: 'Port that the host is running on, defaults to 443 (HTTPS)', + clickToCopy: false, + }, + { + key: 'secure', + label: 'Secure', + type: 'dropdown', + required: true, + readOnly: false, + value: 'true', + description: 'True if the host communicates via secure protocol.', + variables: false, + clickToCopy: false, + options: [ + { + label: 'True', + value: 'true', + }, + { + label: 'False', + value: 'false', + }, + ], + }, + { + key: 'databaseName', + label: 'Database Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Name of your Odoo database', + clickToCopy: false, + }, + { + key: 'email', + label: 'Email Address', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Email Address of the account that will be interacting with the database', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API Key for your Odoo account', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/odoo/auth/is-still-verified.js b/packages/backend/src/apps/odoo/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/odoo/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/odoo/auth/verify-credentials.js b/packages/backend/src/apps/odoo/auth/verify-credentials.js new file mode 100644 index 0000000..30aec78 --- /dev/null +++ b/packages/backend/src/apps/odoo/auth/verify-credentials.js @@ -0,0 +1,15 @@ +import { authenticate } from '../common/xmlrpc-client.js'; + +const verifyCredentials = async ($) => { + try { + await authenticate($); + + await $.auth.set({ + screenName: `${$.auth.data.email} @ ${$.auth.data.databaseName} - ${$.auth.data.host}`, + }); + } catch (error) { + throw new Error('Failed while authorizing!'); + } +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/odoo/common/xmlrpc-client.js b/packages/backend/src/apps/odoo/common/xmlrpc-client.js new file mode 100644 index 0000000..d45cb9b --- /dev/null +++ b/packages/backend/src/apps/odoo/common/xmlrpc-client.js @@ -0,0 +1,53 @@ +import { join } from 'node:path'; +import xmlrpc from 'xmlrpc'; + +export const asyncMethodCall = async ($, { method, params, path }) => { + return new Promise((resolve, reject) => { + const client = getClient($, { path }); + + client.methodCall(method, params, (error, response) => { + if (error != null) { + // something went wrong on the server side, display the error returned by Odoo + reject(error); + } + + resolve(response); + }); + }); +}; + +export const getClient = ($, { path = 'common' }) => { + const host = $.auth.data.host; + const port = Number($.auth.data.port); + const secure = $.auth.data.secure === 'true'; + const createClientFunction = secure + ? xmlrpc.createSecureClient + : xmlrpc.createClient; + + return createClientFunction({ + host, + port, + path: join('/xmlrpc/2', path), + }); +}; + +export const authenticate = async ($) => { + const uid = await asyncMethodCall($, { + method: 'authenticate', + params: [ + $.auth.data.databaseName, + $.auth.data.email, + $.auth.data.apiKey, + [], + ], + }); + + if (!Number.isInteger(uid)) { + // failed to authenticate + throw new Error( + 'Failed to connect to the Odoo server. Please, check the credentials!' + ); + } + + return uid; +}; diff --git a/packages/backend/src/apps/odoo/index.js b/packages/backend/src/apps/odoo/index.js new file mode 100644 index 0000000..181b2c1 --- /dev/null +++ b/packages/backend/src/apps/odoo/index.js @@ -0,0 +1,16 @@ +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Odoo', + key: 'odoo', + iconUrl: '{BASE_URL}/apps/odoo/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/odoo/connection', + supportsConnections: true, + baseUrl: 'https://odoo.com', + apiBaseUrl: '', + primaryColor: '#9c5789', + auth, + actions, +}); diff --git a/packages/backend/src/apps/openai/actions/check-moderation/index.js b/packages/backend/src/apps/openai/actions/check-moderation/index.js new file mode 100644 index 0000000..331acc7 --- /dev/null +++ b/packages/backend/src/apps/openai/actions/check-moderation/index.js @@ -0,0 +1,30 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Check moderation', + key: 'checkModeration', + description: + 'Checks for hate, hate/threatening, self-harm, sexual, sexual/minors, violence, or violence/graphic content in the given text.', + arguments: [ + { + label: 'Input', + key: 'input', + type: 'string', + required: true, + variables: true, + description: 'The text to analyze.', + }, + ], + + async run($) { + const { data } = await $.http.post('/v1/moderations', { + input: $.step.parameters.input, + }); + + const result = data?.results[0]; + + $.setActionItem({ + raw: result, + }); + }, +}); diff --git a/packages/backend/src/apps/openai/actions/index.js b/packages/backend/src/apps/openai/actions/index.js new file mode 100644 index 0000000..45dbad5 --- /dev/null +++ b/packages/backend/src/apps/openai/actions/index.js @@ -0,0 +1,5 @@ +import checkModeration from './check-moderation/index.js'; +import sendPrompt from './send-prompt/index.js'; +import sendChatPrompt from './send-chat-prompt/index.js'; + +export default [checkModeration, sendChatPrompt, sendPrompt]; diff --git a/packages/backend/src/apps/openai/actions/send-chat-prompt/index.js b/packages/backend/src/apps/openai/actions/send-chat-prompt/index.js new file mode 100644 index 0000000..2865a69 --- /dev/null +++ b/packages/backend/src/apps/openai/actions/send-chat-prompt/index.js @@ -0,0 +1,138 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send chat prompt', + key: 'sendChatPrompt', + description: 'Creates a completion for the provided prompt and parameters.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'Add or remove messages as needed', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Stop Sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + description: + 'Single stop sequence where the API will stop generating further tokens. The returned text will not contain the stop sequence.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with Top P probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.`, + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: $.step.parameters.stopSequence || null, + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + messages: $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + }; + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/openai/actions/send-prompt/index.js b/packages/backend/src/apps/openai/actions/send-prompt/index.js new file mode 100644 index 0000000..786b81e --- /dev/null +++ b/packages/backend/src/apps/openai/actions/send-prompt/index.js @@ -0,0 +1,110 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send prompt', + key: 'sendPrompt', + description: 'Creates a completion for the provided prompt and parameters.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Prompt', + key: 'prompt', + type: 'string', + required: true, + variables: true, + description: 'The text to analyze.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Stop Sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + description: + 'Single stop sequence where the API will stop generating further tokens. The returned text will not contain the stop sequence.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with Top P probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.`, + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + prompt: $.step.parameters.prompt, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: $.step.parameters.stopSequence || null, + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + const { data } = await $.http.post('/v1/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/openai/assets/favicon.svg b/packages/backend/src/apps/openai/assets/favicon.svg new file mode 100644 index 0000000..b62b84e --- /dev/null +++ b/packages/backend/src/apps/openai/assets/favicon.svg @@ -0,0 +1,6 @@ + + OpenAI + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/openai/auth/index.js b/packages/backend/src/apps/openai/auth/index.js new file mode 100644 index 0000000..cd9c891 --- /dev/null +++ b/packages/backend/src/apps/openai/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'OpenAI API key of your account.', + docUrl: 'https://automatisch.io/docs/openai#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/openai/auth/is-still-verified.js b/packages/backend/src/apps/openai/auth/is-still-verified.js new file mode 100644 index 0000000..3e6c909 --- /dev/null +++ b/packages/backend/src/apps/openai/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/openai/auth/verify-credentials.js b/packages/backend/src/apps/openai/auth/verify-credentials.js new file mode 100644 index 0000000..7f43f88 --- /dev/null +++ b/packages/backend/src/apps/openai/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/openai/common/add-auth-header.js b/packages/backend/src/apps/openai/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/openai/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/openai/dynamic-data/index.js b/packages/backend/src/apps/openai/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/openai/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/openai/dynamic-data/list-models/index.js b/packages/backend/src/apps/openai/dynamic-data/list-models/index.js new file mode 100644 index 0000000..a8e8153 --- /dev/null +++ b/packages/backend/src/apps/openai/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const response = await $.http.get('/v1/models'); + + const models = response.data.data.map((model) => { + return { + value: model.id, + name: model.id, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/openai/index.js b/packages/backend/src/apps/openai/index.js new file mode 100644 index 0000000..49fee7d --- /dev/null +++ b/packages/backend/src/apps/openai/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'OpenAI', + key: 'openai', + baseUrl: 'https://openai.com', + apiBaseUrl: 'https://api.openai.com', + iconUrl: '{BASE_URL}/apps/openai/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/openai/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js b/packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js new file mode 100644 index 0000000..8e98310 --- /dev/null +++ b/packages/backend/src/apps/openrouter/actions/create-chat-completion/index.js @@ -0,0 +1,157 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create chat completion', + key: 'createChatCompletion', + description: 'Creates a chat completion.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: + 'The prompt(s) to generate completions for, encoded as a list of dict with role and content.', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: `The maximum number of tokens to generate in the completion. The token count of your prompt plus max_tokens cannot exceed the model's context length.`, + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: 'Stop generation if one of these tokens is detected', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'Nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Frequency_penalty penalizes the repetition of words based on their frequency in the generated text. A higher frequency penalty discourages the model from repeating words that have already appeared frequently in the output, promoting diversity and reducing repetition.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Presence penalty determines how much the model penalizes the repetition of words or phrases. A higher presence penalty encourages the model to use a wider variety of words and phrases, making the output more diverse and creative.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const messages = $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + const payload = { + model: $.step.parameters.model, + messages, + stop: nonEmptyStopSequences, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/openrouter/actions/index.js b/packages/backend/src/apps/openrouter/actions/index.js new file mode 100644 index 0000000..cc0e053 --- /dev/null +++ b/packages/backend/src/apps/openrouter/actions/index.js @@ -0,0 +1,3 @@ +import createChatCompletion from './create-chat-completion/index.js'; + +export default [createChatCompletion]; diff --git a/packages/backend/src/apps/openrouter/assets/favicon.svg b/packages/backend/src/apps/openrouter/assets/favicon.svg new file mode 100644 index 0000000..e88f91b --- /dev/null +++ b/packages/backend/src/apps/openrouter/assets/favicon.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/packages/backend/src/apps/openrouter/auth/index.js b/packages/backend/src/apps/openrouter/auth/index.js new file mode 100644 index 0000000..8d260a3 --- /dev/null +++ b/packages/backend/src/apps/openrouter/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'OpenRouter API key of your account.', + docUrl: 'https://automatisch.io/docs/openrouter#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/openrouter/auth/is-still-verified.js b/packages/backend/src/apps/openrouter/auth/is-still-verified.js new file mode 100644 index 0000000..3e6c909 --- /dev/null +++ b/packages/backend/src/apps/openrouter/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/openrouter/auth/verify-credentials.js b/packages/backend/src/apps/openrouter/auth/verify-credentials.js new file mode 100644 index 0000000..7f43f88 --- /dev/null +++ b/packages/backend/src/apps/openrouter/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/openrouter/common/add-auth-header.js b/packages/backend/src/apps/openrouter/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/openrouter/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/openrouter/dynamic-data/index.js b/packages/backend/src/apps/openrouter/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/openrouter/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js b/packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js new file mode 100644 index 0000000..a8e8153 --- /dev/null +++ b/packages/backend/src/apps/openrouter/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const response = await $.http.get('/v1/models'); + + const models = response.data.data.map((model) => { + return { + value: model.id, + name: model.id, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/openrouter/index.js b/packages/backend/src/apps/openrouter/index.js new file mode 100644 index 0000000..12a5067 --- /dev/null +++ b/packages/backend/src/apps/openrouter/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'OpenRouter', + key: 'openrouter', + baseUrl: 'https://openrouter.ai', + apiBaseUrl: 'https://openrouter.ai/api', + iconUrl: '{BASE_URL}/apps/openrouter/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/openrouter/connection', + primaryColor: '#71717a', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/perplexity/actions/index.js b/packages/backend/src/apps/perplexity/actions/index.js new file mode 100644 index 0000000..c95d62f --- /dev/null +++ b/packages/backend/src/apps/perplexity/actions/index.js @@ -0,0 +1,3 @@ +import sendChatPrompt from './send-chat-prompt/index.js'; + +export default [sendChatPrompt]; diff --git a/packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js b/packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js new file mode 100644 index 0000000..bde28cf --- /dev/null +++ b/packages/backend/src/apps/perplexity/actions/send-chat-prompt/index.js @@ -0,0 +1,185 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send chat prompt', + key: 'sendChatPrompt', + description: `Generates a model's response for the given chat conversation.`, + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + options: [ + { + label: 'Sonar Pro', + value: 'sonar-pro', + }, + { + label: 'Sonar', + value: 'sonar', + }, + ], + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'Add or remove messages as needed', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + description: + 'The role of the speaker in this turn of conversation. After the (optional) system message, user and assistant roles should alternate with user then assistant, ending in user.', + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + description: + 'The contents of the message in this turn of conversation.', + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'The amount of randomness in the response, valued between 0 inclusive and 2 exclusive. Higher values are more random, and lower values are more deterministic.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'The nucleus sampling threshold, valued between 0 and 1 inclusive. For each subsequent token, the model considers the results of the tokens with top_p probability mass. We recommend either altering top_k or top_p, but not both.', + }, + { + label: 'Top K', + key: 'topK', + type: 'string', + required: false, + variables: true, + description: + 'The number of tokens to keep for highest top-k filtering, specified as an integer between 0 and 2048 inclusive. If set to 0, top-k filtering is disabled. We recommend either altering top_k or top_p, but not both.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `A multiplicative penalty greater than 0. Values greater than 1.0 penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. A value of 1.0 means no penalty. Incompatible with presence_penalty.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `A value between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. Incompatible with frequency_penalty.`, + }, + { + label: 'Return images', + key: 'returnImages', + type: 'dropdown', + required: false, + variables: true, + value: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + { + label: 'Return related questions', + key: 'returnRelatedQuestions', + type: 'dropdown', + required: false, + variables: true, + value: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + top_p: castFloatOrUndefined($.step.parameters.topP), + top_k: castFloatOrUndefined($.step.parameters.topK), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + messages: $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + return_images: $.step.parameters.returnImages, + return_related_questions: $.step.parameters.returnRelatedQuestons, + }; + + const { data } = await $.http.post('/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/perplexity/assets/favicon.svg b/packages/backend/src/apps/perplexity/assets/favicon.svg new file mode 100644 index 0000000..b27ffc9 --- /dev/null +++ b/packages/backend/src/apps/perplexity/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/perplexity/auth/index.js b/packages/backend/src/apps/perplexity/auth/index.js new file mode 100644 index 0000000..67b870b --- /dev/null +++ b/packages/backend/src/apps/perplexity/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Perplexity API key of your account.', + docUrl: 'https://automatisch.io/docs/perplexity#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/perplexity/auth/is-still-verified.js b/packages/backend/src/apps/perplexity/auth/is-still-verified.js new file mode 100644 index 0000000..3f85395 --- /dev/null +++ b/packages/backend/src/apps/perplexity/auth/is-still-verified.js @@ -0,0 +1,5 @@ +const isStillVerified = async () => { + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/perplexity/auth/verify-credentials.js b/packages/backend/src/apps/perplexity/auth/verify-credentials.js new file mode 100644 index 0000000..07e4f02 --- /dev/null +++ b/packages/backend/src/apps/perplexity/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async () => { + return true; +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/perplexity/common/add-auth-header.js b/packages/backend/src/apps/perplexity/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/perplexity/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/perplexity/index.js b/packages/backend/src/apps/perplexity/index.js new file mode 100644 index 0000000..ed40102 --- /dev/null +++ b/packages/backend/src/apps/perplexity/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Perplexity', + key: 'perplexity', + baseUrl: 'https://perplexity.ai', + apiBaseUrl: 'https://api.perplexity.ai', + iconUrl: '{BASE_URL}/apps/perplexity/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/perplexity/connection', + primaryColor: '#091717', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/create-activity/index.js b/packages/backend/src/apps/pipedrive/actions/create-activity/index.js new file mode 100644 index 0000000..ca2a42b --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/create-activity/index.js @@ -0,0 +1,198 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; + +export default defineAction({ + name: 'Create activity', + key: 'createActivity', + description: 'Creates a new activity.', + arguments: [ + { + label: 'Subject', + key: 'subject', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Assigned To', + key: 'userId', + type: 'dropdown', + required: false, + description: + 'If omitted, the activity will be assigned to the user of the connected account.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + ], + }, + }, + { + label: 'Person', + key: 'personId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listPersons', + }, + ], + }, + }, + { + label: 'Deal', + key: 'dealId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDeals', + }, + ], + }, + }, + { + label: 'Is done?', + key: 'isDone', + type: 'dropdown', + required: false, + description: '', + options: [ + { + label: 'No', + value: 0, + }, + { + label: 'Yes', + value: 1, + }, + ], + }, + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listActivityTypes', + }, + ], + }, + }, + { + label: 'Due Date', + key: 'dueDate', + type: 'string', + required: false, + description: 'Format must be YYYY-MM-DD', + variables: true, + }, + { + label: 'Due Time', + key: 'dueTime', + type: 'string', + required: false, + description: 'Format must be HH:MM', + variables: true, + }, + { + label: 'Duration', + key: 'duration', + type: 'string', + required: false, + description: 'Format must be HH:MM', + variables: true, + }, + { + label: 'Note', + key: 'note', + type: 'string', + required: false, + description: 'Accepts HTML format', + variables: true, + }, + ], + + async run($) { + const { + subject, + organizationId, + userId, + personId, + dealId, + isDone, + type, + dueTime, + dueDate, + duration, + note, + } = $.step.parameters; + + const fields = { + subject: subject, + org_id: organizationId, + user_id: userId, + person_id: personId, + deal_id: dealId, + done: isDone, + type: type, + due_time: dueTime, + due_date: dueDate, + duration: duration, + note: note, + }; + + const body = filterProvidedFields(fields); + + const { + data: { data }, + } = await $.http.post('/api/v1/activities', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/create-deal/index.js b/packages/backend/src/apps/pipedrive/actions/create-deal/index.js new file mode 100644 index 0000000..55493c5 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/create-deal/index.js @@ -0,0 +1,222 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; + +export default defineAction({ + name: 'Create deal', + key: 'createDeal', + description: 'Creates a new deal.', + arguments: [ + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Creation Date', + key: 'addTime', + type: 'string', + required: false, + description: + 'Requires admin access to Pipedrive account. Format: YYYY-MM-DD HH:MM:SS', + variables: true, + }, + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: false, + description: '', + options: [ + { + label: 'Open', + value: 'open', + }, + { + label: 'Won', + value: 'won', + }, + { + label: 'Lost', + value: 'lost', + }, + { + label: 'Deleted', + value: 'deleted', + }, + ], + }, + { + label: 'Lost Reason', + key: 'lostReason', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Stage', + key: 'stageId', + type: 'dropdown', + required: false, + value: '1', + description: + 'The ID of the stage this deal will be added to. If omitted, the deal will be placed in the first stage of the default pipeline.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listStages', + }, + ], + }, + }, + { + label: 'Owner', + key: 'userId', + type: 'dropdown', + required: false, + description: + 'Select user who will be marked as the owner of this deal. If omitted, the authorized user will be used.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + ], + }, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: 'Organization this deal will be associated with.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Person', + key: 'personId', + type: 'dropdown', + required: false, + description: 'Person this deal will be associated with.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listPersons', + }, + ], + }, + }, + { + label: 'Probability', + key: 'probability', + type: 'string', + required: false, + description: + 'The success probability percentage of the deal. Used/shown only when deal_probability for the pipeline of the deal is enabled.', + variables: true, + }, + { + label: 'Expected Close Date', + key: 'expectedCloseDate', + type: 'string', + required: false, + description: + 'The expected close date of the deal. In ISO 8601 format: YYYY-MM-DD.', + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: false, + description: 'The value of the deal. If omitted, value will be set to 0.', + variables: true, + }, + { + label: 'Currency', + key: 'currency', + type: 'dropdown', + required: false, + description: + 'The currency of the deal. Accepts a 3-character currency code. If omitted, currency will be set to the default currency of the authorized user.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCurrencies', + }, + ], + }, + }, + ], + + async run($) { + const { + title, + addTime, + status, + lostReason, + stageId, + userId, + organizationId, + personId, + probability, + expectedCloseDate, + value, + currency, + } = $.step.parameters; + + const fields = { + title: title, + value: value, + add_time: addTime, + status: status, + lost_reason: lostReason, + stage_id: stageId, + user_id: userId, + org_id: organizationId, + person_id: personId, + probability: probability, + expected_close_date: expectedCloseDate, + currency: currency, + }; + + const body = filterProvidedFields(fields); + + const { + data: { data }, + } = await $.http.post('/api/v1/deals', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/create-lead/index.js b/packages/backend/src/apps/pipedrive/actions/create-lead/index.js new file mode 100644 index 0000000..b17129e --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/create-lead/index.js @@ -0,0 +1,181 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; + +export default defineAction({ + name: 'Create lead', + key: 'createLead', + description: 'Creates a new lead.', + arguments: [ + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Person', + key: 'personId', + type: 'dropdown', + required: false, + description: + 'Lead must be associated with at least one person or organization.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listPersons', + }, + ], + }, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: + 'Lead must be associated with at least one person or organization.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Owner', + key: 'ownerId', + type: 'dropdown', + required: false, + description: + 'Select user who will be marked as the owner of this lead. If omitted, the authorized user will be used.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + ], + }, + }, + { + label: 'Lead Labels', + key: 'labelIds', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Label', + key: 'leadLabelId', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadLabels', + }, + ], + }, + }, + ], + }, + { + label: 'Expected Close Date', + key: 'expectedCloseDate', + type: 'string', + required: false, + description: 'E.g. 2023-10-23', + variables: true, + }, + { + label: 'Lead Value', + key: 'value', + type: 'string', + required: false, + description: 'E.g. 150', + variables: true, + }, + { + label: 'Lead Value Currency', + key: 'currency', + type: 'dropdown', + required: false, + description: 'This field is required if a Lead Value amount is provided.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCurrencies', + }, + ], + }, + }, + ], + + async run($) { + const { + title, + personId, + organizationId, + ownerId, + labelIds, + expectedCloseDate, + value, + currency, + } = $.step.parameters; + + const onlyLabelIds = labelIds + .map((labelId) => labelId.leadLabelId) + .filter(Boolean); + + const labelValue = {}; + + if (value) { + labelValue.amount = Number(value); + } + if (currency) { + labelValue.currency = currency; + } + + const fields = { + title: title, + person_id: Number(personId), + organization_id: Number(organizationId), + owner_id: Number(ownerId), + expected_close_date: expectedCloseDate, + label_ids: onlyLabelIds, + value: labelValue, + }; + + const body = filterProvidedFields(fields); + + const { + data: { data }, + } = await $.http.post('/api/v1/leads', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/create-note/index.js b/packages/backend/src/apps/pipedrive/actions/create-note/index.js new file mode 100644 index 0000000..781d87b --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/create-note/index.js @@ -0,0 +1,198 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; + +export default defineAction({ + name: 'Create note', + key: 'createNote', + description: 'Creates a new note.', + arguments: [ + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + description: 'Supports some HTML formatting.', + variables: true, + }, + { + label: 'Deal', + key: 'dealId', + type: 'dropdown', + required: false, + description: + 'Note must be associated with at least one deal, person, organization, or lead.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDeals', + }, + ], + }, + }, + { + label: 'Pin note on specified deal?', + key: 'pinnedDeal', + type: 'dropdown', + required: false, + description: '', + options: [ + { + label: 'No', + value: 0, + }, + { + label: 'Yes', + value: 1, + }, + ], + }, + { + label: 'Person', + key: 'personId', + type: 'dropdown', + required: false, + description: + 'Note must be associated with at least one deal, person, organization, or lead.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listPersons', + }, + ], + }, + }, + { + label: 'Pin note on specified person?', + key: 'pinnedPerson', + type: 'dropdown', + required: false, + description: '', + options: [ + { + label: 'No', + value: 0, + }, + { + label: 'Yes', + value: 1, + }, + ], + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: + 'Note must be associated with at least one deal, person, organization, or lead.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Pin note on specified organization?', + key: 'pinnedOrganization', + type: 'dropdown', + required: false, + description: '', + options: [ + { + label: 'No', + value: 0, + }, + { + label: 'Yes', + value: 1, + }, + ], + }, + { + label: 'Lead', + key: 'leadId', + type: 'dropdown', + required: false, + description: + 'Note must be associated with at least one deal, person, organization, or lead.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeads', + }, + ], + }, + }, + { + label: 'Pin note on specified lead?', + key: 'pinnedLead', + type: 'dropdown', + required: false, + description: '', + options: [ + { + label: 'No', + value: 0, + }, + { + label: 'Yes', + value: 1, + }, + ], + }, + ], + + async run($) { + const { + content, + dealId, + pinnedDeal, + personId, + pinnedPerson, + organizationId, + pinnedOrganization, + leadId, + pinnedLead, + } = $.step.parameters; + + const fields = { + content: content, + deal_id: dealId, + pinned_to_deal_flag: pinnedDeal, + person_id: personId, + pinned_to_person_flag: pinnedPerson, + org_id: organizationId, + pinned_to_organization_flag: pinnedOrganization, + lead_id: leadId, + pinned_to_lead_flag: pinnedLead, + }; + + const body = filterProvidedFields(fields); + + const { + data: { data }, + } = await $.http.post('/api/v1/notes', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/create-organization/index.js b/packages/backend/src/apps/pipedrive/actions/create-organization/index.js new file mode 100644 index 0000000..4f8d495 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/create-organization/index.js @@ -0,0 +1,74 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; + +export default defineAction({ + name: 'Create organization', + key: 'createOrganization', + description: 'Creates a new organization.', + arguments: [ + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Owner', + key: 'ownerId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + ], + }, + }, + { + label: 'Label', + key: 'labelId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizationLabelField', + }, + ], + }, + }, + ], + + async run($) { + const { name, ownerId, labelId } = $.step.parameters; + + const fields = { + name: name, + owner_id: ownerId, + label: labelId, + }; + + const body = filterProvidedFields(fields); + + const { + data: { data }, + } = await $.http.post('/api/v1/organizations', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/create-person/index.js b/packages/backend/src/apps/pipedrive/actions/create-person/index.js new file mode 100644 index 0000000..fa96da2 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/create-person/index.js @@ -0,0 +1,133 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { filterProvidedFields } from '../../common/filter-provided-fields.js'; + +export default defineAction({ + name: 'Create person', + key: 'createPerson', + description: 'Creates a new person.', + arguments: [ + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Owner', + key: 'ownerId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + ], + }, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Emails', + key: 'emails', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Email', + key: 'email', + type: 'string', + required: false, + description: '', + variables: true, + }, + ], + }, + { + label: 'Phones', + key: 'phones', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Phone', + key: 'phone', + type: 'string', + required: false, + description: '', + variables: true, + }, + ], + }, + { + label: 'Label', + key: 'labelId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listPersonLabelField', + }, + ], + }, + }, + ], + + async run($) { + const { name, ownerId, organizationId, labelId } = $.step.parameters; + const emails = $.step.parameters.emails; + const emailValues = emails.map((entry) => entry.email); + const phones = $.step.parameters.phones; + const phoneValues = phones.map((entry) => entry.phone); + + const fields = { + name: name, + owner_id: ownerId, + org_id: organizationId, + email: emailValues, + phone: phoneValues, + label: labelId, + }; + + const body = filterProvidedFields(fields); + + const { + data: { data }, + } = await $.http.post('/api/v1/persons', body); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/actions/index.js b/packages/backend/src/apps/pipedrive/actions/index.js new file mode 100644 index 0000000..81460de --- /dev/null +++ b/packages/backend/src/apps/pipedrive/actions/index.js @@ -0,0 +1,15 @@ +import createActivity from './create-activity/index.js'; +import createDeal from './create-deal/index.js'; +import createLead from './create-lead/index.js'; +import createNote from './create-note/index.js'; +import createOrganization from './create-organization/index.js'; +import createPerson from './create-person/index.js'; + +export default [ + createActivity, + createDeal, + createLead, + createNote, + createOrganization, + createPerson, +]; diff --git a/packages/backend/src/apps/pipedrive/assets/favicon.svg b/packages/backend/src/apps/pipedrive/assets/favicon.svg new file mode 100644 index 0000000..5efad42 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/pipedrive/auth/generate-auth-url.js b/packages/backend/src/apps/pipedrive/auth/generate-auth-url.js new file mode 100644 index 0000000..9079010 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/auth/generate-auth-url.js @@ -0,0 +1,18 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + }); + + const url = `https://oauth.pipedrive.com/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/pipedrive/auth/index.js b/packages/backend/src/apps/pipedrive/auth/index.js new file mode 100644 index 0000000..732ed01 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/pipedrive/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Pipedrive, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/pipedrive/auth/is-still-verified.js b/packages/backend/src/apps/pipedrive/auth/is-still-verified.js new file mode 100644 index 0000000..6d792b1 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/pipedrive/auth/refresh-token.js b/packages/backend/src/apps/pipedrive/auth/refresh-token.js new file mode 100644 index 0000000..c0d84f6 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/auth/refresh-token.js @@ -0,0 +1,35 @@ +import { URLSearchParams } from 'node:url'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await $.http.post( + 'https://oauth.pipedrive.com/oauth/token', + params.toString(), + { + headers, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + await $.auth.set({ + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + tokenType: response.data.token_type, + expiresIn: response.data.expires_in, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/pipedrive/auth/verify-credentials.js b/packages/backend/src/apps/pipedrive/auth/verify-credentials.js new file mode 100644 index 0000000..2cb1b50 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/auth/verify-credentials.js @@ -0,0 +1,58 @@ +import { URLSearchParams } from 'url'; +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: $.auth.data.code, + redirect_uri: redirectUri, + }); + + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await $.http.post( + `https://oauth.pipedrive.com/oauth/token`, + params.toString(), + { headers } + ); + + const { + access_token: accessToken, + api_domain: apiDomain, + expires_in: expiresIn, + refresh_token: refreshToken, + scope: scope, + token_type: tokenType, + } = response.data; + + await $.auth.set({ + accessToken, + apiDomain, + expiresIn, + refreshToken, + scope, + tokenType, + }); + + const user = await getCurrentUser($); + + const screenName = [user.name, user.company_domain] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + userId: user.id, + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/pipedrive/common/add-auth-header.js b/packages/backend/src/apps/pipedrive/common/add-auth-header.js new file mode 100644 index 0000000..679ef60 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/common/add-auth-header.js @@ -0,0 +1,12 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.additionalProperties?.skipAddingAuthHeader) + return requestConfig; + + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/pipedrive/common/filter-provided-fields.js b/packages/backend/src/apps/pipedrive/common/filter-provided-fields.js new file mode 100644 index 0000000..1da4295 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/common/filter-provided-fields.js @@ -0,0 +1,16 @@ +import isObject from 'lodash/isObject.js'; + +export function filterProvidedFields(body) { + return Object.keys(body).reduce((result, key) => { + const value = body[key]; + if (isObject(value)) { + const filteredNestedObj = filterProvidedFields(value); + if (Object.keys(filteredNestedObj).length > 0) { + result[key] = filteredNestedObj; + } + } else if (body[key]) { + result[key] = value; + } + return result; + }, {}); +} diff --git a/packages/backend/src/apps/pipedrive/common/get-current-user.js b/packages/backend/src/apps/pipedrive/common/get-current-user.js new file mode 100644 index 0000000..469245b --- /dev/null +++ b/packages/backend/src/apps/pipedrive/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get(`${$.auth.data.apiDomain}/api/v1/users/me`); + const currentUser = response.data.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/pipedrive/common/set-base-url.js b/packages/backend/src/apps/pipedrive/common/set-base-url.js new file mode 100644 index 0000000..3e81406 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/common/set-base-url.js @@ -0,0 +1,10 @@ +const setBaseUrl = ($, requestConfig) => { + const { apiDomain } = $.auth.data; + + if (apiDomain) { + requestConfig.baseURL = apiDomain; + } + + return requestConfig; +}; +export default setBaseUrl; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/index.js new file mode 100644 index 0000000..42af32e --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/index.js @@ -0,0 +1,25 @@ +import listActivityTypes from './list-activity-types/index.js'; +import listCurrencies from './list-currencies/index.js'; +import listDeals from './list-deals/index.js'; +import listLeadLabels from './list-lead-labels/index.js'; +import listLeads from './list-leads/index.js'; +import listOrganizationLabelField from './list-organization-label-field/index.js'; +import listOrganizations from './list-organizations/index.js'; +import listPersonLabelField from './list-person-label-field/index.js'; +import listPersons from './list-persons/index.js'; +import listStages from './list-stages/index.js'; +import listUsers from './list-users/index.js'; + +export default [ + listActivityTypes, + listCurrencies, + listDeals, + listLeadLabels, + listLeads, + listOrganizationLabelField, + listOrganizations, + listPersonLabelField, + listPersons, + listStages, + listUsers, +]; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-activity-types/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-activity-types/index.js new file mode 100644 index 0000000..ad54e91 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-activity-types/index.js @@ -0,0 +1,25 @@ +export default { + name: 'List activity types', + key: 'listActivityTypes', + + async run($) { + const activityTypes = { + data: [], + }; + + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/activityTypes` + ); + + if (data.data?.length) { + for (const activityType of data.data) { + activityTypes.data.push({ + value: activityType.key_string, + name: activityType.name, + }); + } + } + + return activityTypes; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-currencies/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-currencies/index.js new file mode 100644 index 0000000..9a00e7d --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-currencies/index.js @@ -0,0 +1,25 @@ +export default { + name: 'List currencies', + key: 'listCurrencies', + + async run($) { + const currencies = { + data: [], + }; + + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/currencies` + ); + + if (data.data?.length) { + for (const currency of data.data) { + currencies.data.push({ + value: currency.code, + name: currency.name, + }); + } + } + + return currencies; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-deals/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-deals/index.js new file mode 100644 index 0000000..901ec22 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-deals/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List deals', + key: 'listDeals', + + async run($) { + const deals = { + data: [], + }; + + const params = { + sort: 'add_time DESC', + }; + + const { data } = await $.http.get(`${$.auth.data.apiDomain}/api/v1/deals`, { + params, + }); + + if (data.data?.length) { + for (const deal of data.data) { + deals.data.push({ + value: deal.id, + name: deal.title, + }); + } + } + + return deals; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-lead-labels/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-lead-labels/index.js new file mode 100644 index 0000000..b75c3c7 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-lead-labels/index.js @@ -0,0 +1,26 @@ +export default { + name: 'List lead labels', + key: 'listLeadLabels', + + async run($) { + const leadLabels = { + data: [], + }; + + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/leadLabels` + ); + + if (data.data?.length) { + for (const leadLabel of data.data) { + const name = `${leadLabel.name} (${leadLabel.color})`; + leadLabels.data.push({ + value: leadLabel.id, + name, + }); + } + } + + return leadLabels; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-leads/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-leads/index.js new file mode 100644 index 0000000..e92bda6 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-leads/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List leads', + key: 'listLeads', + + async run($) { + const leads = { + data: [], + }; + + const params = { + sort: 'add_time DESC', + }; + + const { data } = await $.http.get(`${$.auth.data.apiDomain}/api/v1/leads`, { + params, + }); + + if (data.data?.length) { + for (const lead of data.data) { + leads.data.push({ + value: lead.id, + name: lead.title, + }); + } + } + + return leads; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-organization-label-field/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-organization-label-field/index.js new file mode 100644 index 0000000..1cd84ef --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-organization-label-field/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List organization label field', + key: 'listOrganizationLabelField', + + async run($) { + const labelFields = { + data: [], + }; + + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/organizationFields` + ); + + const labelField = data.data.filter((field) => field.key === 'label'); + const labelOptions = labelField[0].options; + + if (labelOptions?.length) { + for (const label of labelOptions) { + labelFields.data.push({ + value: label.id, + name: label.label, + }); + } + } + + return labelFields; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-organizations/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-organizations/index.js new file mode 100644 index 0000000..597d95c --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-organizations/index.js @@ -0,0 +1,25 @@ +export default { + name: 'List organizations', + key: 'listOrganizations', + + async run($) { + const organizations = { + data: [], + }; + + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/organizations` + ); + + if (data.data?.length) { + for (const organization of data.data) { + organizations.data.push({ + value: organization.id, + name: organization.name, + }); + } + } + + return organizations; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-person-label-field/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-person-label-field/index.js new file mode 100644 index 0000000..ce1dfff --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-person-label-field/index.js @@ -0,0 +1,38 @@ +export default { + name: 'List person label field', + key: 'listPersonLabelField', + + async run($) { + const personFields = { + data: [], + }; + + const params = { + start: 0, + limit: 100, + }; + + do { + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/personFields`, + { params } + ); + params.start = data.additional_data?.pagination?.next_start; + + const labelField = data.data?.filter( + (personField) => personField.key === 'label' + ); + const labelOptions = labelField[0].options; + + if (labelOptions?.length) { + for (const label of labelOptions) { + personFields.data.push({ + value: label.id, + name: label.label, + }); + } + } + } while (params.start); + return personFields; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-persons/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-persons/index.js new file mode 100644 index 0000000..dadd841 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-persons/index.js @@ -0,0 +1,33 @@ +export default { + name: 'List persons', + key: 'listPersons', + + async run($) { + const persons = { + data: [], + }; + + const params = { + start: 0, + limit: 100, + }; + + do { + const { data } = await $.http.get( + `${$.auth.data.apiDomain}/api/v1/persons`, + { params } + ); + params.start = data.additional_data?.pagination?.next_start; + + if (data.data?.length) { + for (const person of data.data) { + persons.data.push({ + value: person.id, + name: person.name, + }); + } + } + } while (params.start); + return persons; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-stages/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-stages/index.js new file mode 100644 index 0000000..8a0f489 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-stages/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List stages', + key: 'listStages', + + async run($) { + const stages = { + data: [], + }; + + const { data } = await $.http.get('/api/v1/stages'); + + if (data.data?.length) { + for (const stage of data.data) { + stages.data.push({ + value: stage.id, + name: stage.name, + }); + } + } + + return stages; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/dynamic-data/list-users/index.js b/packages/backend/src/apps/pipedrive/dynamic-data/list-users/index.js new file mode 100644 index 0000000..b07433b --- /dev/null +++ b/packages/backend/src/apps/pipedrive/dynamic-data/list-users/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List users', + key: 'listUsers', + + async run($) { + const users = { + data: [], + }; + + const { data } = await $.http.get(`${$.auth.data.apiDomain}/api/v1/users`); + + if (data.data?.length) { + for (const user of data.data) { + users.data.push({ + value: user.id, + name: user.name, + }); + } + } + + return users; + }, +}; diff --git a/packages/backend/src/apps/pipedrive/index.js b/packages/backend/src/apps/pipedrive/index.js new file mode 100644 index 0000000..9517759 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/index.js @@ -0,0 +1,23 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Pipedrive', + key: 'pipedrive', + baseUrl: '', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/pipedrive/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/pipedrive/connection', + primaryColor: '#FFFFFF', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/pipedrive/triggers/index.js b/packages/backend/src/apps/pipedrive/triggers/index.js new file mode 100644 index 0000000..e2e3b9f --- /dev/null +++ b/packages/backend/src/apps/pipedrive/triggers/index.js @@ -0,0 +1,6 @@ +import newActivities from './new-activities/index.js'; +import newDeals from './new-deals/index.js'; +import newLeads from './new-leads/index.js'; +import newNotes from './new-notes/index.js'; + +export default [newActivities, newDeals, newLeads, newNotes]; diff --git a/packages/backend/src/apps/pipedrive/triggers/new-activities/index.js b/packages/backend/src/apps/pipedrive/triggers/new-activities/index.js new file mode 100644 index 0000000..b8edd6e --- /dev/null +++ b/packages/backend/src/apps/pipedrive/triggers/new-activities/index.js @@ -0,0 +1,38 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New activities', + key: 'newActivities', + pollInterval: 15, + description: 'Triggers when a new activity is created.', + arguments: [], + + async run($) { + const params = { + start: 0, + limit: 100, + sort: 'add_time DESC', + }; + + do { + const { data } = await $.http.get('/api/v1/activities', { + params, + }); + + if (!data?.data?.length) { + return; + } + + params.start = data.additional_data?.pagination?.next_start; + + for (const activity of data.data) { + $.pushTriggerItem({ + raw: activity, + meta: { + internalId: activity.id.toString(), + }, + }); + } + } while (params.start); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/triggers/new-deals/index.js b/packages/backend/src/apps/pipedrive/triggers/new-deals/index.js new file mode 100644 index 0000000..33abde6 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/triggers/new-deals/index.js @@ -0,0 +1,38 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New deals', + key: 'newDeals', + pollInterval: 15, + description: 'Triggers when a new deal is created.', + arguments: [], + + async run($) { + const params = { + start: 0, + limit: 100, + sort: 'add_time DESC', + }; + + do { + const { data } = await $.http.get('/api/v1/deals', { + params, + }); + + if (!data?.data?.length) { + return; + } + + params.start = data.additional_data?.pagination?.next_start; + + for (const deal of data.data) { + $.pushTriggerItem({ + raw: deal, + meta: { + internalId: deal.id.toString(), + }, + }); + } + } while (params.start); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/triggers/new-leads/index.js b/packages/backend/src/apps/pipedrive/triggers/new-leads/index.js new file mode 100644 index 0000000..b29e3fb --- /dev/null +++ b/packages/backend/src/apps/pipedrive/triggers/new-leads/index.js @@ -0,0 +1,38 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New leads', + key: 'newLeads', + pollInterval: 15, + description: 'Triggers when a new lead is created.', + arguments: [], + + async run($) { + const params = { + start: 0, + limit: 100, + sort: 'add_time DESC', + }; + + do { + const { data } = await $.http.get('/api/v1/leads', { + params, + }); + + if (!data?.data?.length) { + return; + } + + params.start = data.additional_data?.pagination?.next_start; + + for (const lead of data.data) { + $.pushTriggerItem({ + raw: lead, + meta: { + internalId: lead.id.toString(), + }, + }); + } + } while (params.start); + }, +}); diff --git a/packages/backend/src/apps/pipedrive/triggers/new-notes/index.js b/packages/backend/src/apps/pipedrive/triggers/new-notes/index.js new file mode 100644 index 0000000..1838831 --- /dev/null +++ b/packages/backend/src/apps/pipedrive/triggers/new-notes/index.js @@ -0,0 +1,38 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New notes', + key: 'newNotes', + pollInterval: 15, + description: 'Triggers when a new note is created.', + arguments: [], + + async run($) { + const params = { + start: 0, + limit: 100, + sort: 'add_time DESC', + }; + + do { + const { data } = await $.http.get('/api/v1/notes', { + params, + }); + + if (!data?.data?.length) { + return; + } + + params.start = data.additional_data?.pagination?.next_start; + + for (const note of data.data) { + $.pushTriggerItem({ + raw: note, + meta: { + internalId: note.id.toString(), + }, + }); + } + } while (params.start); + }, +}); diff --git a/packages/backend/src/apps/placetel/assets/favicon.svg b/packages/backend/src/apps/placetel/assets/favicon.svg new file mode 100644 index 0000000..6df467a --- /dev/null +++ b/packages/backend/src/apps/placetel/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/backend/src/apps/placetel/auth/index.js b/packages/backend/src/apps/placetel/auth/index.js new file mode 100644 index 0000000..85181db --- /dev/null +++ b/packages/backend/src/apps/placetel/auth/index.js @@ -0,0 +1,21 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'apiToken', + label: 'API Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Placetel API Token of your account.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/placetel/auth/is-still-verified.js b/packages/backend/src/apps/placetel/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/placetel/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/placetel/auth/verify-credentials.js b/packages/backend/src/apps/placetel/auth/verify-credentials.js new file mode 100644 index 0000000..0475497 --- /dev/null +++ b/packages/backend/src/apps/placetel/auth/verify-credentials.js @@ -0,0 +1,9 @@ +const verifyCredentials = async ($) => { + const { data } = await $.http.get('/v2/me'); + + await $.auth.set({ + screenName: `${data.name} @ ${data.company}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/placetel/common/add-auth-header.js b/packages/backend/src/apps/placetel/common/add-auth-header.js new file mode 100644 index 0000000..92ac331 --- /dev/null +++ b/packages/backend/src/apps/placetel/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiToken) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/placetel/dynamic-data/index.js b/packages/backend/src/apps/placetel/dynamic-data/index.js new file mode 100644 index 0000000..1c3e14e --- /dev/null +++ b/packages/backend/src/apps/placetel/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listNumbers from './list-numbers/index.js'; + +export default [listNumbers]; diff --git a/packages/backend/src/apps/placetel/dynamic-data/list-numbers/index.js b/packages/backend/src/apps/placetel/dynamic-data/list-numbers/index.js new file mode 100644 index 0000000..eeccca3 --- /dev/null +++ b/packages/backend/src/apps/placetel/dynamic-data/list-numbers/index.js @@ -0,0 +1,27 @@ +export default { + name: 'List numbers', + key: 'listNumbers', + + async run($) { + const numbers = { + data: [], + }; + + const { data } = await $.http.get('/v2/numbers'); + + if (!data) { + return { data: [] }; + } + + if (data.length) { + for (const number of data) { + numbers.data.push({ + value: number.number, + name: number.number, + }); + } + } + + return numbers; + }, +}; diff --git a/packages/backend/src/apps/placetel/index.js b/packages/backend/src/apps/placetel/index.js new file mode 100644 index 0000000..e8dfcf3 --- /dev/null +++ b/packages/backend/src/apps/placetel/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Placetel', + key: 'placetel', + iconUrl: '{BASE_URL}/apps/placetel/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/placetel/connection', + supportsConnections: true, + baseUrl: 'https://placetel.de', + apiBaseUrl: 'https://api.placetel.de', + primaryColor: '#069dd9', + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/placetel/triggers/hungup-call/index.js b/packages/backend/src/apps/placetel/triggers/hungup-call/index.js new file mode 100644 index 0000000..f0f0fba --- /dev/null +++ b/packages/backend/src/apps/placetel/triggers/hungup-call/index.js @@ -0,0 +1,145 @@ +import Crypto from 'crypto'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import getRawBody from 'raw-body'; + +export default defineTrigger({ + name: 'Hungup Call', + key: 'hungupCall', + type: 'webhook', + description: 'Triggers when a call is hungup.', + arguments: [ + { + label: 'Types', + key: 'types', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: true, + description: + 'Filter events by type. If the types are not specified, all types will be notified.', + variables: true, + options: [ + { label: 'All', value: 'all' }, + { label: 'Voicemail', value: 'voicemail' }, + { label: 'Missed', value: 'missed' }, + { label: 'Blocked', value: 'blocked' }, + { label: 'Accepted', value: 'accepted' }, + { label: 'Busy', value: 'busy' }, + { label: 'Cancelled', value: 'cancelled' }, + { label: 'Unavailable', value: 'unavailable' }, + { label: 'Congestion', value: 'congestion' }, + ], + }, + ], + }, + { + label: 'Numbers', + key: 'numbers', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Number', + key: 'number', + type: 'dropdown', + required: true, + description: + 'Filter events by number. If the numbers are not specified, all numbers will be notified.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listNumbers', + }, + ], + }, + }, + ], + }, + ], + + async run($) { + const stringBody = await getRawBody($.request, { + length: $.request.headers['content-length'], + encoding: true, + }); + + const jsonRequestBody = JSON.parse(stringBody); + + let types = $.step.parameters.types.map((type) => type.type); + + if (types.length === 0) { + types = ['all']; + } + + if (types.includes(jsonRequestBody.type) || types.includes('all')) { + const dataItem = { + raw: jsonRequestBody, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + } + }, + + async testRun($) { + const types = $.step.parameters.types.map((type) => type.type); + + const sampleEventData = { + type: types[0] || 'missed', + duration: 0, + from: '01662223344', + to: '02229997766', + call_id: + '9c81d4776d3977d920a558cbd4f0950b168e32bd4b5cc141a85b6ed3aa530107', + event: 'HungUp', + direction: 'in', + }; + + const dataItem = { + raw: sampleEventData, + meta: { + internalId: sampleEventData.call_id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const numbers = $.step.parameters.numbers + .map((number) => number.number) + .filter(Boolean); + + const subscriptionPayload = { + service: 'string', + url: $.webhookUrl, + incoming: false, + outgoing: false, + hungup: true, + accepted: false, + phone: false, + numbers, + }; + + const { data } = await $.http.put('/v2/subscriptions', subscriptionPayload); + + await $.flow.setRemoteWebhookId(data.id); + }, + + async unregisterHook($) { + await $.http.delete(`/v2/subscriptions/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/apps/placetel/triggers/index.js b/packages/backend/src/apps/placetel/triggers/index.js new file mode 100644 index 0000000..8bb892f --- /dev/null +++ b/packages/backend/src/apps/placetel/triggers/index.js @@ -0,0 +1,3 @@ +import hungupCall from './hungup-call/index.js'; + +export default [hungupCall]; diff --git a/packages/backend/src/apps/postgresql/actions/delete/index.js b/packages/backend/src/apps/postgresql/actions/delete/index.js new file mode 100644 index 0000000..1f78160 --- /dev/null +++ b/packages/backend/src/apps/postgresql/actions/delete/index.js @@ -0,0 +1,109 @@ +import defineAction from '../../../../helpers/define-action.js'; +import getClient from '../../common/postgres-client.js'; +import setParams from '../../common/set-run-time-parameters.js'; +import whereClauseOperators from '../../common/where-clause-operators.js'; + +export default defineAction({ + name: 'Delete', + key: 'delete', + description: 'Delete rows found based on the given where clause entries.', + arguments: [ + { + label: 'Schema name', + key: 'schema', + type: 'string', + value: 'public', + required: true, + variables: true, + }, + { + label: 'Table name', + key: 'table', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Where clause entries', + key: 'whereClauseEntries', + type: 'dynamic', + required: true, + fields: [ + { + label: 'Column name', + key: 'columnName', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Operator', + key: 'operator', + type: 'dropdown', + required: true, + variables: true, + options: whereClauseOperators, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Run-time parameters', + key: 'params', + type: 'dynamic', + required: false, + description: 'Change run-time configuration parameters with SET command', + fields: [ + { + label: 'Parameter name', + key: 'parameter', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + const client = getClient($); + await setParams(client, $.step.parameters.params); + + const whereClauseEntries = $.step.parameters.whereClauseEntries; + + const response = await client($.step.parameters.table) + .withSchema($.step.parameters.schema) + .returning('*') + .where((builder) => { + for (const whereClauseEntry of whereClauseEntries) { + const { columnName, operator, value } = whereClauseEntry; + + if (columnName) { + builder.where(columnName, operator, value); + } + } + }) + .del(); + + client.destroy(); + + $.setActionItem({ + raw: { + rows: response, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/postgresql/actions/index.js b/packages/backend/src/apps/postgresql/actions/index.js new file mode 100644 index 0000000..b3cbbb1 --- /dev/null +++ b/packages/backend/src/apps/postgresql/actions/index.js @@ -0,0 +1,6 @@ +import insertAction from './insert/index.js'; +import updateAction from './update/index.js'; +import deleteAction from './delete/index.js'; +import SQLQuery from './sql-query/index.js'; + +export default [insertAction, updateAction, deleteAction, SQLQuery]; diff --git a/packages/backend/src/apps/postgresql/actions/insert/index.js b/packages/backend/src/apps/postgresql/actions/insert/index.js new file mode 100644 index 0000000..ecf0efb --- /dev/null +++ b/packages/backend/src/apps/postgresql/actions/insert/index.js @@ -0,0 +1,95 @@ +import defineAction from '../../../../helpers/define-action.js'; +import getClient from '../../common/postgres-client.js'; +import setParams from '../../common/set-run-time-parameters.js'; + +export default defineAction({ + name: 'Insert', + key: 'insert', + description: 'Create a new row in a table in specified schema.', + arguments: [ + { + label: 'Schema name', + key: 'schema', + type: 'string', + value: 'public', + required: true, + variables: true, + }, + { + label: 'Table name', + key: 'table', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Column - value entries', + key: 'columnValueEntries', + type: 'dynamic', + required: true, + description: 'Table columns with values', + fields: [ + { + label: 'Column name', + key: 'columnName', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Run-time parameters', + key: 'params', + type: 'dynamic', + required: true, + description: 'Change run-time configuration parameters with SET command', + fields: [ + { + label: 'Parameter name', + key: 'parameter', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + const client = getClient($); + await setParams(client, $.step.parameters.params); + + const fields = $.step.parameters.columnValueEntries; + const data = fields.reduce( + (result, { columnName, value }) => ({ + ...result, + [columnName]: value, + }), + {} + ); + + const response = await client($.step.parameters.table) + .withSchema($.step.parameters.schema) + .returning('*') + .insert(data); + + client.destroy(); + + $.setActionItem({ raw: response[0] }); + }, +}); diff --git a/packages/backend/src/apps/postgresql/actions/sql-query/index.js b/packages/backend/src/apps/postgresql/actions/sql-query/index.js new file mode 100644 index 0000000..221259d --- /dev/null +++ b/packages/backend/src/apps/postgresql/actions/sql-query/index.js @@ -0,0 +1,57 @@ +import defineAction from '../../../../helpers/define-action.js'; +import getClient from '../../common/postgres-client.js'; +import setParams from '../../common/set-run-time-parameters.js'; + +export default defineAction({ + name: 'SQL query', + key: 'SQLQuery', + description: 'Executes the given SQL statement.', + arguments: [ + { + label: 'SQL statement', + key: 'queryStatement', + type: 'string', + value: 'public', + required: true, + variables: true, + }, + { + label: 'Run-time parameters', + key: 'params', + type: 'dynamic', + required: false, + description: 'Change run-time configuration parameters with SET command', + fields: [ + { + label: 'Parameter name', + key: 'parameter', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + const client = getClient($); + await setParams(client, $.step.parameters.params); + + const queryStatemnt = $.step.parameters.queryStatement; + const { rows } = await client.raw(queryStatemnt); + client.destroy(); + + $.setActionItem({ + raw: { + rows, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/postgresql/actions/update/index.js b/packages/backend/src/apps/postgresql/actions/update/index.js new file mode 100644 index 0000000..5f9cc36 --- /dev/null +++ b/packages/backend/src/apps/postgresql/actions/update/index.js @@ -0,0 +1,141 @@ +import defineAction from '../../../../helpers/define-action.js'; +import getClient from '../../common/postgres-client.js'; +import setParams from '../../common/set-run-time-parameters.js'; +import whereClauseOperators from '../../common/where-clause-operators.js'; + +export default defineAction({ + name: 'Update', + key: 'update', + description: 'Update rows found based on the given where clause entries.', + arguments: [ + { + label: 'Schema name', + key: 'schema', + type: 'string', + value: 'public', + required: true, + variables: true, + }, + { + label: 'Table name', + key: 'table', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Where clause entries', + key: 'whereClauseEntries', + type: 'dynamic', + required: true, + fields: [ + { + label: 'Column name', + key: 'columnName', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Operator', + key: 'operator', + type: 'dropdown', + required: true, + variables: true, + options: whereClauseOperators, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Column - value entries', + key: 'columnValueEntries', + type: 'dynamic', + required: true, + description: 'Table columns with values', + fields: [ + { + label: 'Column name', + key: 'columnName', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Run-time parameters', + key: 'params', + type: 'dynamic', + required: false, + description: 'Change run-time configuration parameters with SET command', + fields: [ + { + label: 'Parameter name', + key: 'parameter', + type: 'string', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + const client = getClient($); + await setParams(client, $.step.parameters.params); + + const whereClauseEntries = $.step.parameters.whereClauseEntries; + + const fields = $.step.parameters.columnValueEntries; + const data = fields.reduce( + (result, { columnName, value }) => ({ + ...result, + [columnName]: value, + }), + {} + ); + + const response = await client($.step.parameters.table) + .withSchema($.step.parameters.schema) + .returning('*') + .where((builder) => { + for (const whereClauseEntry of whereClauseEntries) { + const { columnName, operator, value } = whereClauseEntry; + + if (columnName) { + builder.where(columnName, operator, value); + } + } + }) + .update(data); + + client.destroy(); + + $.setActionItem({ + raw: { + rows: response, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/postgresql/assets/favicon.svg b/packages/backend/src/apps/postgresql/assets/favicon.svg new file mode 100644 index 0000000..0bdb3e3 --- /dev/null +++ b/packages/backend/src/apps/postgresql/assets/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/backend/src/apps/postgresql/auth/index.js b/packages/backend/src/apps/postgresql/auth/index.js new file mode 100644 index 0000000..e839ac2 --- /dev/null +++ b/packages/backend/src/apps/postgresql/auth/index.js @@ -0,0 +1,98 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'version', + label: 'PostgreSQL version', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'The version of PostgreSQL database that user want to connect with.', + clickToCopy: false, + }, + { + key: 'host', + label: 'Host', + type: 'string', + required: true, + readOnly: false, + value: '127.0.0.1', + placeholder: null, + description: 'The host of the PostgreSQL database.', + clickToCopy: false, + }, + { + key: 'port', + label: 'Port', + type: 'string', + required: true, + readOnly: false, + value: '5432', + placeholder: null, + description: 'The port of the PostgreSQL database.', + clickToCopy: false, + }, + { + key: 'enableSsl', + label: 'Enable SSL', + type: 'dropdown', + required: true, + readOnly: false, + value: 'false', + description: 'The port of the PostgreSQL database.', + variables: false, + clickToCopy: false, + options: [ + { + label: 'True', + value: 'true', + }, + { + label: 'False', + value: 'false', + }, + ], + }, + { + key: 'database', + label: 'Database name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'The database name of the PostgreSQL database.', + clickToCopy: false, + }, + { + key: 'user', + label: 'Database username', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'The user who has access on postgres database.', + clickToCopy: false, + }, + { + key: 'password', + label: 'Password', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'The password of the PostgreSQL database user.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/postgresql/auth/is-still-verified.js b/packages/backend/src/apps/postgresql/auth/is-still-verified.js new file mode 100644 index 0000000..270d415 --- /dev/null +++ b/packages/backend/src/apps/postgresql/auth/is-still-verified.js @@ -0,0 +1,9 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/postgresql/auth/verify-credentials.js b/packages/backend/src/apps/postgresql/auth/verify-credentials.js new file mode 100644 index 0000000..2249e60 --- /dev/null +++ b/packages/backend/src/apps/postgresql/auth/verify-credentials.js @@ -0,0 +1,25 @@ +import logger from '../../../helpers/logger.js'; +import getClient from '../common/postgres-client.js'; + +const verifyCredentials = async ($) => { + const client = getClient($); + const checkConnection = await client.raw('SELECT 1'); + client.destroy(); + + logger.debug(checkConnection); + + await $.auth.set({ + screenName: `${$.auth.data.user}@${$.auth.data.host}:${$.auth.data.port}/${$.auth.data.database}`, + client: 'pg', + version: $.auth.data.version, + host: $.auth.data.host, + port: Number($.auth.data.port), + enableSsl: + $.auth.data.enableSsl === 'true' || $.auth.data.enableSsl === true, + user: $.auth.data.user, + password: $.auth.data.password, + database: $.auth.data.database, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/postgresql/common/postgres-client.js b/packages/backend/src/apps/postgresql/common/postgres-client.js new file mode 100644 index 0000000..e91e045 --- /dev/null +++ b/packages/backend/src/apps/postgresql/common/postgres-client.js @@ -0,0 +1,20 @@ +import knex from 'knex'; + +const getClient = ($) => { + const client = knex({ + client: 'pg', + version: $.auth.data.version, + connection: { + host: $.auth.data.host, + port: Number($.auth.data.port), + ssl: $.auth.data.enableSsl === 'true' || $.auth.data.enableSsl === true, + user: $.auth.data.user, + password: $.auth.data.password, + database: $.auth.data.database, + }, + }); + + return client; +}; + +export default getClient; diff --git a/packages/backend/src/apps/postgresql/common/set-run-time-parameters.js b/packages/backend/src/apps/postgresql/common/set-run-time-parameters.js new file mode 100644 index 0000000..bf9a1b7 --- /dev/null +++ b/packages/backend/src/apps/postgresql/common/set-run-time-parameters.js @@ -0,0 +1,14 @@ +const setParams = async (client, params) => { + for (const { parameter, value } of params) { + if (parameter) { + const bindings = { + parameter, + value, + }; + + await client.raw('SET :parameter: = :value:', bindings); + } + } +}; + +export default setParams; diff --git a/packages/backend/src/apps/postgresql/common/where-clause-operators.js b/packages/backend/src/apps/postgresql/common/where-clause-operators.js new file mode 100644 index 0000000..5242a54 --- /dev/null +++ b/packages/backend/src/apps/postgresql/common/where-clause-operators.js @@ -0,0 +1,60 @@ +const whereClauseOperators = [ + { + value: "=", + label: "=" + }, + { + value: ">", + label: ">" + }, + { + value: "<", + label: "<" + }, + { + value: ">=", + label: ">=" + }, + { + value: "<=", + label: "<=" + }, + { + value: "<>", + label: "<>" + }, + { + value: "!=", + label: "!=" + }, + { + value: "AND", + label: "AND" + }, + { + value: "OR", + label: "OR" + }, + { + value: "IN", + label: "IN" + }, + { + value: "BETWEEN", + label: "BETWEEN" + }, + { + value: "LIKE", + label: "LIKE" + }, + { + value: "IS NULL", + label: "IS NULL" + }, + { + value: "NOT", + label: "NOT" + } +]; + +export default whereClauseOperators; diff --git a/packages/backend/src/apps/postgresql/index.js b/packages/backend/src/apps/postgresql/index.js new file mode 100644 index 0000000..94de12e --- /dev/null +++ b/packages/backend/src/apps/postgresql/index.js @@ -0,0 +1,16 @@ +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'PostgreSQL', + key: 'postgresql', + iconUrl: '{BASE_URL}/apps/postgresql/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/postgresql/connection', + supportsConnections: true, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#336791', + auth, + actions, +}); diff --git a/packages/backend/src/apps/pushover/actions/index.js b/packages/backend/src/apps/pushover/actions/index.js new file mode 100644 index 0000000..ba1bd4b --- /dev/null +++ b/packages/backend/src/apps/pushover/actions/index.js @@ -0,0 +1,3 @@ +import sendAPushoverNotification from './send-a-pushover-notification/index.js'; + +export default [sendAPushoverNotification]; diff --git a/packages/backend/src/apps/pushover/actions/send-a-pushover-notification/index.js b/packages/backend/src/apps/pushover/actions/send-a-pushover-notification/index.js new file mode 100644 index 0000000..5923298 --- /dev/null +++ b/packages/backend/src/apps/pushover/actions/send-a-pushover-notification/index.js @@ -0,0 +1,133 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send a Pushover Notification', + key: 'sendPushoverNotification', + description: + 'Generates a Pushover notification on the devices you have subscribed to.', + arguments: [ + { + label: 'Title', + key: 'title', + type: 'string', + required: false, + description: 'An optional title displayed with the message.', + variables: true, + }, + { + label: 'Message', + key: 'message', + type: 'string', + required: true, + description: 'The main message text of your notification.', + variables: true, + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Lowest (no notification, just in-app message)', value: -2 }, + { label: 'Low (no sound or vibration)', value: -1 }, + { label: 'Normal', value: 0 }, + { label: 'High (bypass quiet hours, highlight)', value: 1 }, + { + label: 'Emergency (repeat every 30 seconds until acknowledged)', + value: 2, + }, + ], + }, + { + label: 'Sound', + key: 'sound', + type: 'dropdown', + required: false, + description: 'Optional sound to override your default.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSounds', + }, + ], + }, + }, + { + label: 'URL', + key: 'url', + type: 'string', + required: false, + description: 'URL to display with message.', + variables: true, + }, + { + label: 'URL Title', + key: 'urlTitle', + type: 'string', + required: false, + description: + 'Title of URL to display, otherwise URL itself will be displayed.', + variables: true, + }, + { + label: 'Devices', + key: 'devices', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Device', + key: 'device', + type: 'dropdown', + required: false, + description: + 'Restrict sending to just these devices on your account.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listDevices', + }, + ], + }, + }, + ], + }, + ], + + async run($) { + const { title, message, priority, sound, url, urlTitle } = + $.step.parameters; + + const devices = $.step.parameters.devices; + const allDevices = devices.map((device) => device.device).join(','); + + const payload = { + token: $.auth.data.apiToken, + user: $.auth.data.userKey, + title, + message, + priority, + sound, + url, + url_title: urlTitle, + device: allDevices, + }; + + const { data } = await $.http.post('/1/messages.json', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/pushover/assets/favicon.svg b/packages/backend/src/apps/pushover/assets/favicon.svg new file mode 100644 index 0000000..28492a9 --- /dev/null +++ b/packages/backend/src/apps/pushover/assets/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/backend/src/apps/pushover/auth/index.js b/packages/backend/src/apps/pushover/auth/index.js new file mode 100644 index 0000000..06c90ab --- /dev/null +++ b/packages/backend/src/apps/pushover/auth/index.js @@ -0,0 +1,44 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'userKey', + label: 'User Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'apiToken', + label: 'API Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/pushover/auth/is-still-verified.js b/packages/backend/src/apps/pushover/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/pushover/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/pushover/auth/verify-credentials.js b/packages/backend/src/apps/pushover/auth/verify-credentials.js new file mode 100644 index 0000000..27b5ec2 --- /dev/null +++ b/packages/backend/src/apps/pushover/auth/verify-credentials.js @@ -0,0 +1,24 @@ +import HttpError from '../../../errors/http.js'; + +const verifyCredentials = async ($) => { + try { + await $.http.post(`/1/users/validate.json`, { + token: $.auth.data.apiToken, + user: $.auth.data.userKey, + }); + } catch (error) { + const noDeviceError = 'user is valid but has no active devices'; + const hasNoDeviceError = + error.response?.data?.errors?.includes(noDeviceError); + + if (!hasNoDeviceError) { + throw new HttpError(error); + } + } + + await $.auth.set({ + screenName: $.auth.data.screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/pushover/dynamic-data/index.js b/packages/backend/src/apps/pushover/dynamic-data/index.js new file mode 100644 index 0000000..1faccbb --- /dev/null +++ b/packages/backend/src/apps/pushover/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listDevices from './list-devices/index.js'; +import listSounds from './list-sounds/index.js'; + +export default [listDevices, listSounds]; diff --git a/packages/backend/src/apps/pushover/dynamic-data/list-devices/index.js b/packages/backend/src/apps/pushover/dynamic-data/list-devices/index.js new file mode 100644 index 0000000..3b3f74c --- /dev/null +++ b/packages/backend/src/apps/pushover/dynamic-data/list-devices/index.js @@ -0,0 +1,28 @@ +export default { + name: 'List devices', + key: 'listDevices', + + async run($) { + const devices = { + data: [], + }; + + const { data } = await $.http.post(`/1/users/validate.json`, { + token: $.auth.data.apiToken, + user: $.auth.data.userKey, + }); + + if (!data?.devices?.length) { + return; + } + + for (const device of data.devices) { + devices.data.push({ + value: device, + name: device, + }); + } + + return devices; + }, +}; diff --git a/packages/backend/src/apps/pushover/dynamic-data/list-sounds/index.js b/packages/backend/src/apps/pushover/dynamic-data/list-sounds/index.js new file mode 100644 index 0000000..f089479 --- /dev/null +++ b/packages/backend/src/apps/pushover/dynamic-data/list-sounds/index.js @@ -0,0 +1,26 @@ +export default { + name: 'List sounds', + key: 'listSounds', + + async run($) { + const sounds = { + data: [], + }; + + const params = { + token: $.auth.data.apiToken, + }; + + const { data } = await $.http.get(`/1/sounds.json`, { params }); + const soundEntries = Object.entries(data.sounds); + + for (const [key, value] of soundEntries) { + sounds.data.push({ + value: key, + name: value, + }); + } + + return sounds; + }, +}; diff --git a/packages/backend/src/apps/pushover/index.js b/packages/backend/src/apps/pushover/index.js new file mode 100644 index 0000000..c867f3a --- /dev/null +++ b/packages/backend/src/apps/pushover/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Pushover', + key: 'pushover', + baseUrl: 'https://pushover.net', + apiBaseUrl: 'https://api.pushover.net', + iconUrl: '{BASE_URL}/apps/pushover/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/pushover/connection', + primaryColor: '#249DF1', + supportsConnections: true, + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/reddit/actions/create-link-post/index.js b/packages/backend/src/apps/reddit/actions/create-link-post/index.js new file mode 100644 index 0000000..9277023 --- /dev/null +++ b/packages/backend/src/apps/reddit/actions/create-link-post/index.js @@ -0,0 +1,53 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { URLSearchParams } from 'url'; + +export default defineAction({ + name: 'Create link post', + key: 'createLinkPost', + description: 'Create a new link post within a subreddit.', + arguments: [ + { + label: 'Title', + key: 'title', + type: 'string', + required: true, + description: + 'Heading for the recent post. Limited to 300 characters or less.', + variables: true, + }, + { + label: 'Subreddit', + key: 'subreddit', + type: 'string', + required: true, + description: 'The subreddit for posting. Note: Exclude /r/.', + variables: true, + }, + { + label: 'Url', + key: 'url', + type: 'string', + required: true, + description: '', + variables: true, + }, + ], + + async run($) { + const { title, subreddit, url } = $.step.parameters; + + const params = new URLSearchParams({ + kind: 'link', + api_type: 'json', + title: title, + sr: subreddit, + url: url, + }); + + const { data } = await $.http.post('/api/submit', params.toString()); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/reddit/actions/index.js b/packages/backend/src/apps/reddit/actions/index.js new file mode 100644 index 0000000..3570d52 --- /dev/null +++ b/packages/backend/src/apps/reddit/actions/index.js @@ -0,0 +1,3 @@ +import createLinkPost from './create-link-post/index.js'; + +export default [createLinkPost]; diff --git a/packages/backend/src/apps/reddit/assets/favicon.svg b/packages/backend/src/apps/reddit/assets/favicon.svg new file mode 100644 index 0000000..e41ae32 --- /dev/null +++ b/packages/backend/src/apps/reddit/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/reddit/auth/generate-auth-url.js b/packages/backend/src/apps/reddit/auth/generate-auth-url.js new file mode 100644 index 0000000..ccbcda5 --- /dev/null +++ b/packages/backend/src/apps/reddit/auth/generate-auth-url.js @@ -0,0 +1,25 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const state = Math.random().toString(); + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + response_type: 'code', + redirect_uri: redirectUri, + duration: 'permanent', + scope: authScope.join(' '), + state, + }); + + const url = `https://www.reddit.com/api/v1/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + originalState: state, + }); +} diff --git a/packages/backend/src/apps/reddit/auth/index.js b/packages/backend/src/apps/reddit/auth/index.js new file mode 100644 index 0000000..bd8b811 --- /dev/null +++ b/packages/backend/src/apps/reddit/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/reddit/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Reddit, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/reddit/auth/is-still-verified.js b/packages/backend/src/apps/reddit/auth/is-still-verified.js new file mode 100644 index 0000000..0896289 --- /dev/null +++ b/packages/backend/src/apps/reddit/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/reddit/auth/refresh-token.js b/packages/backend/src/apps/reddit/auth/refresh-token.js new file mode 100644 index 0000000..200d505 --- /dev/null +++ b/packages/backend/src/apps/reddit/auth/refresh-token.js @@ -0,0 +1,33 @@ +import { URLSearchParams } from 'node:url'; + +const refreshToken = async ($) => { + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + }; + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://www.reddit.com/api/v1/access_token', + params.toString(), + { + headers, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: data.scope, + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/reddit/auth/verify-credentials.js b/packages/backend/src/apps/reddit/auth/verify-credentials.js new file mode 100644 index 0000000..c39dea6 --- /dev/null +++ b/packages/backend/src/apps/reddit/auth/verify-credentials.js @@ -0,0 +1,47 @@ +import getCurrentUser from '../common/get-current-user.js'; +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + if ($.auth.data.originalState !== $.auth.data.state) { + throw new Error(`The 'state' parameter does not match.`); + } + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + }; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: $.auth.data.code, + redirect_uri: redirectUri, + }); + + const { data } = await $.http.post( + 'https://www.reddit.com/api/v1/access_token', + params.toString(), + { headers } + ); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + const screenName = currentUser?.name; + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/reddit/common/add-auth-header.js b/packages/backend/src/apps/reddit/common/add-auth-header.js new file mode 100644 index 0000000..0c27f71 --- /dev/null +++ b/packages/backend/src/apps/reddit/common/add-auth-header.js @@ -0,0 +1,25 @@ +import appConfig from '../../../config/app.js'; + +const addAuthHeader = ($, requestConfig) => { + const screenName = $.auth.data?.screenName; + if (screenName) { + requestConfig.headers[ + 'User-Agent' + ] = `web:automatisch:${appConfig.version} (by /u/${screenName})`; + } else { + requestConfig.headers[ + 'User-Agent' + ] = `web:automatisch:${appConfig.version}`; + } + + if ( + !requestConfig.additionalProperties?.skipAddingAuthHeader && + $.auth.data?.accessToken + ) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/reddit/common/auth-scope.js b/packages/backend/src/apps/reddit/common/auth-scope.js new file mode 100644 index 0000000..612bf47 --- /dev/null +++ b/packages/backend/src/apps/reddit/common/auth-scope.js @@ -0,0 +1,3 @@ +const authScope = ['identity', 'read', 'account', 'submit']; + +export default authScope; diff --git a/packages/backend/src/apps/reddit/common/get-current-user.js b/packages/backend/src/apps/reddit/common/get-current-user.js new file mode 100644 index 0000000..f76851c --- /dev/null +++ b/packages/backend/src/apps/reddit/common/get-current-user.js @@ -0,0 +1,6 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get('/api/v1/me'); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/reddit/index.js b/packages/backend/src/apps/reddit/index.js new file mode 100644 index 0000000..0df4dcb --- /dev/null +++ b/packages/backend/src/apps/reddit/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Reddit', + key: 'reddit', + baseUrl: 'https://www.reddit.com', + apiBaseUrl: 'https://oauth.reddit.com', + iconUrl: '{BASE_URL}/apps/reddit/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/reddit/connection', + primaryColor: '#FF4500', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, +}); diff --git a/packages/backend/src/apps/reddit/triggers/index.js b/packages/backend/src/apps/reddit/triggers/index.js new file mode 100644 index 0000000..c8a159d --- /dev/null +++ b/packages/backend/src/apps/reddit/triggers/index.js @@ -0,0 +1,3 @@ +import newPostsMatchingSearch from './new-posts-matching-search/index.js'; + +export default [newPostsMatchingSearch]; diff --git a/packages/backend/src/apps/reddit/triggers/new-posts-matching-search/index.js b/packages/backend/src/apps/reddit/triggers/new-posts-matching-search/index.js new file mode 100644 index 0000000..6c58694 --- /dev/null +++ b/packages/backend/src/apps/reddit/triggers/new-posts-matching-search/index.js @@ -0,0 +1,48 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New posts matching search', + key: 'newPostsMatchingSearch', + pollInterval: 15, + description: 'Triggers when a search string matches a new post.', + arguments: [ + { + label: 'Search Query', + key: 'searchQuery', + type: 'string', + required: true, + description: + 'The term or expression to look for, restricted to 512 characters. If your query contains periods (e.g., automatisch.io), ensure it is enclosed in quotes ("automatisch.io").', + variables: true, + }, + ], + + async run($) { + const { searchQuery } = $.step.parameters; + const params = { + q: searchQuery, + type: 'link', + sort: 'new', + limit: 100, + after: undefined, + }; + + do { + const { data } = await $.http.get('/search', { + params, + }); + params.after = data.data.after; + + if (data.data.children?.length) { + for (const item of data.data.children) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.data.id, + }, + }); + } + } + } while (params.after); + }, +}); diff --git a/packages/backend/src/apps/removebg/actions/index.js b/packages/backend/src/apps/removebg/actions/index.js new file mode 100644 index 0000000..f99cc56 --- /dev/null +++ b/packages/backend/src/apps/removebg/actions/index.js @@ -0,0 +1,3 @@ +import removeImageBackground from './remove-image-background/index.js'; + +export default [removeImageBackground]; diff --git a/packages/backend/src/apps/removebg/actions/remove-image-background/index.js b/packages/backend/src/apps/removebg/actions/remove-image-background/index.js new file mode 100644 index 0000000..c080822 --- /dev/null +++ b/packages/backend/src/apps/removebg/actions/remove-image-background/index.js @@ -0,0 +1,83 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Remove image background', + key: 'removeImageBackground', + description: 'Removes the background of an image.', + arguments: [ + { + label: 'Image file', + key: 'imageFileB64', + type: 'string', + required: true, + variables: true, + description: + 'Provide a JPG or PNG file in Base64 format, up to 12 MB (see remove.bg/supported-images)', + }, + { + label: 'Size', + key: 'size', + type: 'dropdown', + required: true, + value: 'auto', + options: [ + { label: 'Auto', value: 'auto' }, + { label: 'Preview (up to 0.25 megapixels)', value: 'preview' }, + { label: 'Full (up to 10 megapixels)', value: 'full' }, + ], + }, + { + label: 'Background color', + key: 'bgColor', + type: 'string', + description: + 'Adds a solid color background. Can be a hex color code (e.g. 81d4fa, fff) or a color name (e.g. green)', + required: false, + }, + { + label: 'Background image URL', + key: 'bgImageUrl', + type: 'string', + description: 'Adds a background image from a URL.', + required: false, + }, + { + label: 'Output image format', + key: 'outputFormat', + type: 'dropdown', + description: 'Note: Use PNG to preserve transparency', + required: true, + value: 'auto', + options: [ + { label: 'Auto', value: 'auto' }, + { label: 'PNG', value: 'png' }, + { label: 'JPG', value: 'jpg' }, + { label: 'ZIP', value: 'zip' }, + ], + }, + ], + async run($) { + const imageFileB64 = $.step.parameters.imageFileB64; + const size = $.step.parameters.size; + const bgColor = $.step.parameters.bgColor; + const bgImageUrl = $.step.parameters.bgImageUrl; + const outputFormat = $.step.parameters.outputFormat; + + const body = JSON.stringify({ + image_file_b64: imageFileB64, + size: size, + bg_color: bgColor, + bg_image_url: bgImageUrl, + format: outputFormat, + }); + + const response = await $.http.post('/removebg', body, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/removebg/assets/favicon.svg b/packages/backend/src/apps/removebg/assets/favicon.svg new file mode 100644 index 0000000..8019755 --- /dev/null +++ b/packages/backend/src/apps/removebg/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/removebg/auth/index.js b/packages/backend/src/apps/removebg/auth/index.js new file mode 100644 index 0000000..f007aaa --- /dev/null +++ b/packages/backend/src/apps/removebg/auth/index.js @@ -0,0 +1,33 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API key of the remove.bg API service.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/removebg/auth/is-still-verified.js b/packages/backend/src/apps/removebg/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/removebg/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/removebg/auth/verify-credentials.js b/packages/backend/src/apps/removebg/auth/verify-credentials.js new file mode 100644 index 0000000..98441bb --- /dev/null +++ b/packages/backend/src/apps/removebg/auth/verify-credentials.js @@ -0,0 +1,10 @@ +const verifyCredentials = async ($) => { + await $.http.get('/account'); + + await $.auth.set({ + screenName: $.auth.data.screenName, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/removebg/common/add-auth-header.js b/packages/backend/src/apps/removebg/common/add-auth-header.js new file mode 100644 index 0000000..c5018c3 --- /dev/null +++ b/packages/backend/src/apps/removebg/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['X-API-Key'] = `${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/removebg/index.js b/packages/backend/src/apps/removebg/index.js new file mode 100644 index 0000000..366b7e2 --- /dev/null +++ b/packages/backend/src/apps/removebg/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Remove.bg', + key: 'removebg', + iconUrl: '{BASE_URL}/apps/removebg/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/removebg/connection', + supportsConnections: true, + baseUrl: 'https://www.remove.bg', + apiBaseUrl: 'https://api.remove.bg/v1.0', + primaryColor: '#55636c', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/rss/assets/favicon.svg b/packages/backend/src/apps/rss/assets/favicon.svg new file mode 100644 index 0000000..ce961d2 --- /dev/null +++ b/packages/backend/src/apps/rss/assets/favicon.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/rss/index.js b/packages/backend/src/apps/rss/index.js new file mode 100644 index 0000000..1b4b51d --- /dev/null +++ b/packages/backend/src/apps/rss/index.js @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'RSS', + key: 'rss', + iconUrl: '{BASE_URL}/apps/rss/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/rss/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#ff8800', + triggers, +}); diff --git a/packages/backend/src/apps/rss/triggers/index.js b/packages/backend/src/apps/rss/triggers/index.js new file mode 100644 index 0000000..229deea --- /dev/null +++ b/packages/backend/src/apps/rss/triggers/index.js @@ -0,0 +1,3 @@ +import newItemsInFeed from './new-items-in-feed/index.js'; + +export default [newItemsInFeed]; diff --git a/packages/backend/src/apps/rss/triggers/new-items-in-feed/index.js b/packages/backend/src/apps/rss/triggers/new-items-in-feed/index.js new file mode 100644 index 0000000..f4a6baa --- /dev/null +++ b/packages/backend/src/apps/rss/triggers/new-items-in-feed/index.js @@ -0,0 +1,23 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import newItemsInFeed from './new-items-in-feed.js'; + +export default defineTrigger({ + name: 'New items in feed', + key: 'newItemsInFeed', + description: 'Triggers on new RSS feed item.', + pollInterval: 15, + arguments: [ + { + label: 'Feed URL', + key: 'feedUrl', + type: 'string', + required: true, + description: 'Paste your publicly accessible RSS URL here.', + variables: false, + }, + ], + + async run($) { + await newItemsInFeed($); + }, +}); diff --git a/packages/backend/src/apps/rss/triggers/new-items-in-feed/new-items-in-feed.js b/packages/backend/src/apps/rss/triggers/new-items-in-feed/new-items-in-feed.js new file mode 100644 index 0000000..3ce458d --- /dev/null +++ b/packages/backend/src/apps/rss/triggers/new-items-in-feed/new-items-in-feed.js @@ -0,0 +1,44 @@ +import { XMLParser } from 'fast-xml-parser'; +import bcrypt from 'bcrypt'; + +const getInternalId = async (item) => { + if (item.guid) { + return typeof item.guid === 'object' + ? item.guid['#text'].toString() + : item.guid.toString(); + } else if (item.id) { + return typeof item.id === 'object' + ? item.id['#text'].toString() + : item.id.toString(); + } + + return await hashItem(JSON.stringify(item)); +}; + +const hashItem = async (value) => { + return await bcrypt.hash(value, 1); +}; + +const newItemsInFeed = async ($) => { + const { data } = await $.http.get($.step.parameters.feedUrl); + const parser = new XMLParser({ + ignoreAttributes: false, + }); + const parsedData = parser.parse(data); + + // naive implementation to cover atom and rss feeds + const items = parsedData.rss?.channel?.item || parsedData.feed?.entry || []; + + for (const item of items) { + const dataItem = { + raw: item, + meta: { + internalId: await getInternalId(item), + }, + }; + + $.pushTriggerItem(dataItem); + } +}; + +export default newItemsInFeed; diff --git a/packages/backend/src/apps/salesforce/actions/create-attachment/index.js b/packages/backend/src/apps/salesforce/actions/create-attachment/index.js new file mode 100644 index 0000000..8a79704 --- /dev/null +++ b/packages/backend/src/apps/salesforce/actions/create-attachment/index.js @@ -0,0 +1,52 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create attachment', + key: 'createAttachment', + description: + 'Creates an attachment of a specified object by given parent ID.', + arguments: [ + { + label: 'Parent ID', + key: 'parentId', + type: 'string', + required: true, + variables: true, + description: + 'ID of the parent object of the attachment. The following objects are supported as parents of attachments: Account, Asset, Campaign, Case, Contact, Contract, Custom objects, EmailMessage, EmailTemplate, Event, Lead, Opportunity, Product2, Solution, Task', + }, + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + variables: true, + description: 'Name of the attached file. Maximum size is 255 characters.', + }, + { + label: 'Body', + key: 'body', + type: 'string', + required: true, + variables: true, + description: 'File data. (Max size is 25MB)', + }, + ], + + async run($) { + const { parentId, name, body } = $.step.parameters; + + const options = { + ParentId: parentId, + Name: name, + Body: body, + }; + + const { data } = await $.http.post( + '/services/data/v56.0/sobjects/Attachment/', + options + ); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/salesforce/actions/execute-query/index.js b/packages/backend/src/apps/salesforce/actions/execute-query/index.js new file mode 100644 index 0000000..9884232 --- /dev/null +++ b/packages/backend/src/apps/salesforce/actions/execute-query/index.js @@ -0,0 +1,31 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Execute query', + key: 'executeQuery', + description: 'Executes a SOQL query in Salesforce.', + arguments: [ + { + label: 'Query', + key: 'query', + type: 'string', + required: true, + description: + 'Salesforce query string. For example: SELECT Id, Name FROM Account', + variables: true, + }, + ], + + async run($) { + const query = $.step.parameters.query; + + const options = { + params: { + q: query, + }, + }; + + const { data } = await $.http.get('/services/data/v56.0/query', options); + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/salesforce/actions/find-partially-matching-record/index.js b/packages/backend/src/apps/salesforce/actions/find-partially-matching-record/index.js new file mode 100644 index 0000000..328c57c --- /dev/null +++ b/packages/backend/src/apps/salesforce/actions/find-partially-matching-record/index.js @@ -0,0 +1,101 @@ +import defineAction from '../../../../helpers/define-action.js'; +import listObjects from '../../dynamic-data/list-objects/index.js'; +import listFields from '../../dynamic-data/list-fields/index.js'; + +export default defineAction({ + name: 'Find partially matching record', + key: 'findPartiallyMatchingRecord', + description: 'Finds a record of a specified object by a field containing a value.', + arguments: [ + { + label: 'Object', + key: 'object', + type: 'dropdown', + required: true, + variables: true, + description: 'Pick which type of object you want to search for.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listObjects', + }, + ], + }, + }, + { + label: 'Field', + key: 'field', + type: 'dropdown', + description: 'Pick which field to search by', + required: true, + variables: true, + dependsOn: ['parameters.object'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFields', + }, + { + name: 'parameters.object', + value: '{parameters.object}', + }, + ], + }, + }, + { + label: 'Search value to contain', + key: 'searchValue', + type: 'string', + required: true, + variables: true, + description: 'The value to search for in the field.', + }, + ], + + async run($) { + const sanitizedSearchValue = $.step.parameters.searchValue.replaceAll(`'`, `\\'`); + + // validate given object + const objects = await listObjects.run($); + const validObject = objects.data.find((object) => object.value === $.step.parameters.object); + + if (!validObject) { + throw new Error(`The "${$.step.parameters.object}" object does not exist.`); + } + + // validate given object field + const fields = await listFields.run($); + const validField = fields.data.find((field) => field.value === $.step.parameters.field); + + if (!validField) { + throw new Error(`The "${$.step.parameters.field}" field does not exist on the "${$.step.parameters.object}" object.`); + } + + const query = ` + SELECT + FIELDS(ALL) + FROM + ${$.step.parameters.object} + WHERE + ${$.step.parameters.field} LIKE '%${sanitizedSearchValue}%' + LIMIT 1 + `; + + const options = { + params: { + q: query, + }, + }; + + const { data } = await $.http.get('/services/data/v61.0/query', options); + const record = data.records[0]; + + $.setActionItem({ raw: record }); + }, +}); diff --git a/packages/backend/src/apps/salesforce/actions/find-record/index.js b/packages/backend/src/apps/salesforce/actions/find-record/index.js new file mode 100644 index 0000000..e696e62 --- /dev/null +++ b/packages/backend/src/apps/salesforce/actions/find-record/index.js @@ -0,0 +1,80 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find record', + key: 'findRecord', + description: 'Finds a record of a specified object by a field and value.', + arguments: [ + { + label: 'Object', + key: 'object', + type: 'dropdown', + required: true, + variables: true, + description: 'Pick which type of object you want to search for.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listObjects', + }, + ], + }, + }, + { + label: 'Field', + key: 'field', + type: 'dropdown', + description: 'Pick which field to search by', + required: true, + variables: true, + dependsOn: ['parameters.object'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFields', + }, + { + name: 'parameters.object', + value: '{parameters.object}', + }, + ], + }, + }, + { + label: 'Search value', + key: 'searchValue', + type: 'string', + required: true, + variables: true, + }, + ], + + async run($) { + const query = ` + SELECT + FIELDS(ALL) + FROM + ${$.step.parameters.object} + WHERE + ${$.step.parameters.field} = '${$.step.parameters.searchValue}' + LIMIT 1 + `; + + const options = { + params: { + q: query, + }, + }; + + const { data } = await $.http.get('/services/data/v56.0/query', options); + const record = data.records[0]; + + $.setActionItem({ raw: record }); + }, +}); diff --git a/packages/backend/src/apps/salesforce/actions/index.js b/packages/backend/src/apps/salesforce/actions/index.js new file mode 100644 index 0000000..89e3e3d --- /dev/null +++ b/packages/backend/src/apps/salesforce/actions/index.js @@ -0,0 +1,6 @@ +import createAttachment from './create-attachment/index.js'; +import executeQuery from './execute-query/index.js'; +import findRecord from './find-record/index.js'; +import findPartiallyMatchingRecord from './find-partially-matching-record/index.js'; + +export default [findRecord, findPartiallyMatchingRecord, createAttachment, executeQuery]; diff --git a/packages/backend/src/apps/salesforce/assets/favicon.svg b/packages/backend/src/apps/salesforce/assets/favicon.svg new file mode 100644 index 0000000..e82db67 --- /dev/null +++ b/packages/backend/src/apps/salesforce/assets/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/salesforce/auth/generate-auth-url.js b/packages/backend/src/apps/salesforce/auth/generate-auth-url.js new file mode 100644 index 0000000..cb21c14 --- /dev/null +++ b/packages/backend/src/apps/salesforce/auth/generate-auth-url.js @@ -0,0 +1,17 @@ +import qs from 'qs'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = qs.stringify({ + client_id: $.auth.data.consumerKey, + redirect_uri: redirectUri, + response_type: 'code', + }); + + await $.auth.set({ + url: `${$.auth.data.oauth2Url}/authorize?${searchParams}`, + }); +} diff --git a/packages/backend/src/apps/salesforce/auth/index.js b/packages/backend/src/apps/salesforce/auth/index.js new file mode 100644 index 0000000..06d344d --- /dev/null +++ b/packages/backend/src/apps/salesforce/auth/index.js @@ -0,0 +1,69 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/salesforce/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Salesforce OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'oauth2Url', + label: 'Salesforce Environment', + type: 'dropdown', + required: true, + readOnly: false, + value: 'https://login.salesforce.com/services/oauth2', + placeholder: null, + description: 'Most people should choose the default, "production".', + clickToCopy: false, + options: [ + { + label: 'production', + value: 'https://login.salesforce.com/services/oauth2', + }, + { + label: 'sandbox', + value: 'https://test.salesforce.com/services/oauth2', + }, + ], + }, + { + key: 'consumerKey', + label: 'Consumer Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'Consumer Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + refreshToken, + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/salesforce/auth/is-still-verified.js b/packages/backend/src/apps/salesforce/auth/is-still-verified.js new file mode 100644 index 0000000..f59ee3b --- /dev/null +++ b/packages/backend/src/apps/salesforce/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/salesforce/auth/refresh-token.js b/packages/backend/src/apps/salesforce/auth/refresh-token.js new file mode 100644 index 0000000..07ae487 --- /dev/null +++ b/packages/backend/src/apps/salesforce/auth/refresh-token.js @@ -0,0 +1,24 @@ +import qs from 'querystring'; + +const refreshToken = async ($) => { + const searchParams = qs.stringify({ + grant_type: 'refresh_token', + client_id: $.auth.data.consumerKey, + client_secret: $.auth.data.consumerSecret, + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + `${$.auth.data.oauth2Url}/token?${searchParams}` + ); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + idToken: data.id_token, + instanceUrl: data.instance_url, + signature: data.signature, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/salesforce/auth/verify-credentials.js b/packages/backend/src/apps/salesforce/auth/verify-credentials.js new file mode 100644 index 0000000..ce65eca --- /dev/null +++ b/packages/backend/src/apps/salesforce/auth/verify-credentials.js @@ -0,0 +1,38 @@ +import getCurrentUser from '../common/get-current-user.js'; +import qs from 'qs'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = qs.stringify({ + code: $.auth.data.code, + grant_type: 'authorization_code', + client_id: $.auth.data.consumerKey, + client_secret: $.auth.data.consumerSecret, + redirect_uri: redirectUri, + }); + const { data } = await $.http.post( + `${$.auth.data.oauth2Url}/token?${searchParams}` + ); + + await $.auth.set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + tokenType: data.token_type, + idToken: data.id_token, + instanceUrl: data.instance_url, + signature: data.signature, + userId: data.id, + screenName: data.instance_url, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + screenName: `${currentUser.displayName} - ${data.instance_url}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/salesforce/common/add-auth-header.js b/packages/backend/src/apps/salesforce/common/add-auth-header.js new file mode 100644 index 0000000..453863d --- /dev/null +++ b/packages/backend/src/apps/salesforce/common/add-auth-header.js @@ -0,0 +1,15 @@ +const addAuthHeader = ($, requestConfig) => { + const { instanceUrl, tokenType, accessToken } = $.auth.data; + + if (instanceUrl) { + requestConfig.baseURL = instanceUrl; + } + + if (tokenType && accessToken) { + requestConfig.headers.Authorization = `${tokenType} ${accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/salesforce/common/get-current-user.js b/packages/backend/src/apps/salesforce/common/get-current-user.js new file mode 100644 index 0000000..02d57b5 --- /dev/null +++ b/packages/backend/src/apps/salesforce/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/services/data/v55.0/chatter/users/me'); + const currentUser = response.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/salesforce/dynamic-data/index.js b/packages/backend/src/apps/salesforce/dynamic-data/index.js new file mode 100644 index 0000000..a5e46e4 --- /dev/null +++ b/packages/backend/src/apps/salesforce/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listObjects from './list-objects/index.js'; +import listFields from './list-fields/index.js'; + +export default [listObjects, listFields]; diff --git a/packages/backend/src/apps/salesforce/dynamic-data/list-fields/index.js b/packages/backend/src/apps/salesforce/dynamic-data/list-fields/index.js new file mode 100644 index 0000000..c4f20dc --- /dev/null +++ b/packages/backend/src/apps/salesforce/dynamic-data/list-fields/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List fields', + key: 'listFields', + + async run($) { + const { object } = $.step.parameters; + + if (!object) return { data: [] }; + + const response = await $.http.get( + `/services/data/v56.0/sobjects/${object}/describe` + ); + + const fields = response.data.fields.map((field) => { + return { + value: field.name, + name: field.label, + }; + }); + + return { data: fields }; + }, +}; diff --git a/packages/backend/src/apps/salesforce/dynamic-data/list-objects/index.js b/packages/backend/src/apps/salesforce/dynamic-data/list-objects/index.js new file mode 100644 index 0000000..dc670cf --- /dev/null +++ b/packages/backend/src/apps/salesforce/dynamic-data/list-objects/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List objects', + key: 'listObjects', + + async run($) { + const response = await $.http.get('/services/data/v56.0/sobjects'); + + const objects = response.data.sobjects.map((object) => { + return { + value: object.name, + name: object.label, + }; + }); + + return { data: objects }; + }, +}; diff --git a/packages/backend/src/apps/salesforce/index.js b/packages/backend/src/apps/salesforce/index.js new file mode 100644 index 0000000..e216954 --- /dev/null +++ b/packages/backend/src/apps/salesforce/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Salesforce', + key: 'salesforce', + iconUrl: '{BASE_URL}/apps/salesforce/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/connections/salesforce', + supportsConnections: true, + baseUrl: 'https://salesforce.com', + apiBaseUrl: '', + primaryColor: '#00A1E0', + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/salesforce/triggers/index.js b/packages/backend/src/apps/salesforce/triggers/index.js new file mode 100644 index 0000000..66fb62c --- /dev/null +++ b/packages/backend/src/apps/salesforce/triggers/index.js @@ -0,0 +1,3 @@ +import updatedFieldInRecords from './updated-field-in-records/index.js'; + +export default [updatedFieldInRecords]; diff --git a/packages/backend/src/apps/salesforce/triggers/updated-field-in-records/index.js b/packages/backend/src/apps/salesforce/triggers/updated-field-in-records/index.js new file mode 100644 index 0000000..9f948a7 --- /dev/null +++ b/packages/backend/src/apps/salesforce/triggers/updated-field-in-records/index.js @@ -0,0 +1,55 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import updatedFieldInRecords from './updated-field-in-records.js'; + +export default defineTrigger({ + name: 'Updated field in records', + key: 'updatedFieldInRecords', + pollInterval: 15, + description: 'Triggers when a field is updated in a record.', + arguments: [ + { + label: 'Object', + key: 'object', + type: 'dropdown', + required: true, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listObjects', + }, + ], + }, + }, + { + label: 'Field', + key: 'field', + type: 'dropdown', + description: 'Track updates by this field', + required: true, + variables: false, + dependsOn: ['parameters.object'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFields', + }, + { + name: 'parameters.object', + value: '{parameters.object}', + }, + ], + }, + }, + ], + + async run($) { + await updatedFieldInRecords($); + }, +}); diff --git a/packages/backend/src/apps/salesforce/triggers/updated-field-in-records/updated-field-in-records.js b/packages/backend/src/apps/salesforce/triggers/updated-field-in-records/updated-field-in-records.js new file mode 100644 index 0000000..b451d00 --- /dev/null +++ b/packages/backend/src/apps/salesforce/triggers/updated-field-in-records/updated-field-in-records.js @@ -0,0 +1,43 @@ +function getQuery(object, limit, offset) { + return ` + SELECT + FIELDS(ALL) + FROM + ${object} + ORDER BY LastModifiedDate DESC + LIMIT ${limit} + OFFSET ${offset} + `; +} + +const updatedFieldInRecord = async ($) => { + const limit = 200; + const field = $.step.parameters.field; + const object = $.step.parameters.object; + + let response; + let offset = 0; + do { + const options = { + params: { + q: getQuery(object, limit, offset), + }, + }; + + response = await $.http.get('/services/data/v56.0/query', options); + const records = response.data.records; + + for (const record of records) { + $.pushTriggerItem({ + raw: record, + meta: { + internalId: `${record.Id}-${record[field]}`, + }, + }); + } + + offset = offset + limit; + } while (response.data.records?.length === limit); +}; + +export default updatedFieldInRecord; diff --git a/packages/backend/src/apps/scheduler/assets/favicon.svg b/packages/backend/src/apps/scheduler/assets/favicon.svg new file mode 100644 index 0000000..359793b --- /dev/null +++ b/packages/backend/src/apps/scheduler/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/scheduler/common/cron-times.js b/packages/backend/src/apps/scheduler/common/cron-times.js new file mode 100644 index 0000000..333c0a2 --- /dev/null +++ b/packages/backend/src/apps/scheduler/common/cron-times.js @@ -0,0 +1,12 @@ +const cronTimes = { + everyNMinutes: (n) => `*/${n} * * * *`, + everyNMinutesExcludingWeekends: (n) => `*/${n} * * * 1-5`, + everyHour: '0 * * * *', + everyHourExcludingWeekends: '0 * * * 1-5', + everyDayAt: (hour) => `0 ${hour} * * *`, + everyDayExcludingWeekendsAt: (hour) => `0 ${hour} * * 1-5`, + everyWeekOnAndAt: (weekday, hour) => `0 ${hour} * * ${weekday}`, + everyMonthOnAndAt: (day, hour) => `0 ${hour} ${day} * *`, +}; + +export default cronTimes; diff --git a/packages/backend/src/apps/scheduler/common/get-date-time-object.js b/packages/backend/src/apps/scheduler/common/get-date-time-object.js new file mode 100644 index 0000000..f0d4635 --- /dev/null +++ b/packages/backend/src/apps/scheduler/common/get-date-time-object.js @@ -0,0 +1,14 @@ +import { DateTime } from 'luxon'; + +export default function getDateTimeObjectRepresentation(dateTime) { + const defaults = dateTime.toObject(); + + return { + ...defaults, + ISO_date_time: dateTime.toISO(), + pretty_date: dateTime.toLocaleString(DateTime.DATE_MED), + pretty_time: dateTime.toLocaleString(DateTime.TIME_WITH_SECONDS), + pretty_day_of_week: dateTime.toFormat('cccc'), + day_of_week: dateTime.weekday, + }; +} diff --git a/packages/backend/src/apps/scheduler/common/get-next-cron-date-time.js b/packages/backend/src/apps/scheduler/common/get-next-cron-date-time.js new file mode 100644 index 0000000..0c0a77a --- /dev/null +++ b/packages/backend/src/apps/scheduler/common/get-next-cron-date-time.js @@ -0,0 +1,12 @@ +import { DateTime } from 'luxon'; +import cronParser from 'cron-parser'; + +export default function getNextCronDateTime(cronString) { + const cronDate = cronParser.parseExpression(cronString); + const matchingNextCronDateTime = cronDate.next(); + const matchingNextDateTime = DateTime.fromJSDate( + matchingNextCronDateTime.toDate() + ); + + return matchingNextDateTime; +} diff --git a/packages/backend/src/apps/scheduler/index.js b/packages/backend/src/apps/scheduler/index.js new file mode 100644 index 0000000..6a1fc93 --- /dev/null +++ b/packages/backend/src/apps/scheduler/index.js @@ -0,0 +1,15 @@ +import defineApp from '../../helpers/define-app.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Scheduler', + key: 'scheduler', + iconUrl: '{BASE_URL}/apps/scheduler/assets/favicon.svg', + docUrl: 'https://automatisch.io/docs/scheduler', + authDocUrl: '{DOCS_URL}/apps/scheduler/connection', + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#0059F7', + supportsConnections: false, + triggers, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/every-day/index.js b/packages/backend/src/apps/scheduler/triggers/every-day/index.js new file mode 100644 index 0000000..7e15164 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-day/index.js @@ -0,0 +1,166 @@ +import { DateTime } from 'luxon'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import cronTimes from '../../common/cron-times.js'; +import getNextCronDateTime from '../../common/get-next-cron-date-time.js'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object.js'; + +export default defineTrigger({ + name: 'Every day', + key: 'everyDay', + description: 'Triggers every day.', + arguments: [ + { + label: 'Trigger on weekends?', + key: 'triggersOnWeekend', + type: 'dropdown', + description: 'Should this flow trigger on Saturday and Sunday?', + required: true, + value: true, + variables: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + { + label: 'Time of day', + key: 'hour', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '00:00', + value: 0, + }, + { + label: '01:00', + value: 1, + }, + { + label: '02:00', + value: 2, + }, + { + label: '03:00', + value: 3, + }, + { + label: '04:00', + value: 4, + }, + { + label: '05:00', + value: 5, + }, + { + label: '06:00', + value: 6, + }, + { + label: '07:00', + value: 7, + }, + { + label: '08:00', + value: 8, + }, + { + label: '09:00', + value: 9, + }, + { + label: '10:00', + value: 10, + }, + { + label: '11:00', + value: 11, + }, + { + label: '12:00', + value: 12, + }, + { + label: '13:00', + value: 13, + }, + { + label: '14:00', + value: 14, + }, + { + label: '15:00', + value: 15, + }, + { + label: '16:00', + value: 16, + }, + { + label: '17:00', + value: 17, + }, + { + label: '18:00', + value: 18, + }, + { + label: '19:00', + value: 19, + }, + { + label: '20:00', + value: 20, + }, + { + label: '21:00', + value: 21, + }, + { + label: '22:00', + value: 22, + }, + { + label: '23:00', + value: 23, + }, + ], + }, + ], + + getInterval(parameters) { + if (parameters.triggersOnWeekend) { + return cronTimes.everyDayAt(parameters.hour); + } + + return cronTimes.everyDayExcludingWeekendsAt(parameters.hour); + }, + + async run($) { + const nextCronDateTime = getNextCronDateTime( + this.getInterval($.step.parameters) + ); + const dateTime = DateTime.now(); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation( + $.execution.testRun ? nextCronDateTime : dateTime + ); + + const dataItem = { + raw: dateTimeObjectRepresentation, + meta: { + internalId: dateTime.toMillis().toString(), + }, + }; + + $.pushTriggerItem(dataItem); + }, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/every-hour/index.js b/packages/backend/src/apps/scheduler/triggers/every-hour/index.js new file mode 100644 index 0000000..a3dd917 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-hour/index.js @@ -0,0 +1,60 @@ +import { DateTime } from 'luxon'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import cronTimes from '../../common/cron-times.js'; +import getNextCronDateTime from '../../common/get-next-cron-date-time.js'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object.js'; + +export default defineTrigger({ + name: 'Every hour', + key: 'everyHour', + description: 'Triggers every hour.', + arguments: [ + { + label: 'Trigger on weekends?', + key: 'triggersOnWeekend', + type: 'dropdown', + description: 'Should this flow trigger on Saturday and Sunday?', + required: true, + value: true, + variables: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + ], + + getInterval(parameters) { + if (parameters.triggersOnWeekend) { + return cronTimes.everyHour; + } + + return cronTimes.everyHourExcludingWeekends; + }, + + async run($) { + const nextCronDateTime = getNextCronDateTime( + this.getInterval($.step.parameters) + ); + const dateTime = DateTime.now(); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation( + $.execution.testRun ? nextCronDateTime : dateTime + ); + + const dataItem = { + raw: dateTimeObjectRepresentation, + meta: { + internalId: dateTime.toMillis().toString(), + }, + }; + + $.pushTriggerItem(dataItem); + }, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/every-month/index.js b/packages/backend/src/apps/scheduler/triggers/every-month/index.js new file mode 100644 index 0000000..36a78d6 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-month/index.js @@ -0,0 +1,282 @@ +import { DateTime } from 'luxon'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import cronTimes from '../../common/cron-times.js'; +import getNextCronDateTime from '../../common/get-next-cron-date-time.js'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object.js'; + +export default defineTrigger({ + name: 'Every month', + key: 'everyMonth', + description: 'Triggers every month.', + arguments: [ + { + label: 'Day of the month', + key: 'day', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '1', + value: 1, + }, + { + label: '2', + value: 2, + }, + { + label: '3', + value: 3, + }, + { + label: '4', + value: 4, + }, + { + label: '5', + value: 5, + }, + { + label: '6', + value: 6, + }, + { + label: '7', + value: 7, + }, + { + label: '8', + value: 8, + }, + { + label: '9', + value: 9, + }, + { + label: '10', + value: 10, + }, + { + label: '11', + value: 11, + }, + { + label: '12', + value: 12, + }, + { + label: '13', + value: 13, + }, + { + label: '14', + value: 14, + }, + { + label: '15', + value: 15, + }, + { + label: '16', + value: 16, + }, + { + label: '17', + value: 17, + }, + { + label: '18', + value: 18, + }, + { + label: '19', + value: 19, + }, + { + label: '20', + value: 20, + }, + { + label: '21', + value: 21, + }, + { + label: '22', + value: 22, + }, + { + label: '23', + value: 23, + }, + { + label: '24', + value: 24, + }, + { + label: '25', + value: 25, + }, + { + label: '26', + value: 26, + }, + { + label: '27', + value: 27, + }, + { + label: '28', + value: 28, + }, + { + label: '29', + value: 29, + }, + { + label: '30', + value: 30, + }, + { + label: '31', + value: 31, + }, + ], + }, + { + label: 'Time of day', + key: 'hour', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '00:00', + value: 0, + }, + { + label: '01:00', + value: 1, + }, + { + label: '02:00', + value: 2, + }, + { + label: '03:00', + value: 3, + }, + { + label: '04:00', + value: 4, + }, + { + label: '05:00', + value: 5, + }, + { + label: '06:00', + value: 6, + }, + { + label: '07:00', + value: 7, + }, + { + label: '08:00', + value: 8, + }, + { + label: '09:00', + value: 9, + }, + { + label: '10:00', + value: 10, + }, + { + label: '11:00', + value: 11, + }, + { + label: '12:00', + value: 12, + }, + { + label: '13:00', + value: 13, + }, + { + label: '14:00', + value: 14, + }, + { + label: '15:00', + value: 15, + }, + { + label: '16:00', + value: 16, + }, + { + label: '17:00', + value: 17, + }, + { + label: '18:00', + value: 18, + }, + { + label: '19:00', + value: 19, + }, + { + label: '20:00', + value: 20, + }, + { + label: '21:00', + value: 21, + }, + { + label: '22:00', + value: 22, + }, + { + label: '23:00', + value: 23, + }, + ], + }, + ], + + getInterval(parameters) { + const interval = cronTimes.everyMonthOnAndAt( + parameters.day, + parameters.hour + ); + + return interval; + }, + + async run($) { + const nextCronDateTime = getNextCronDateTime( + this.getInterval($.step.parameters) + ); + const dateTime = DateTime.now(); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation( + $.execution.testRun ? nextCronDateTime : dateTime + ); + + const dataItem = { + raw: dateTimeObjectRepresentation, + meta: { + internalId: dateTime.toMillis().toString(), + }, + }; + + $.pushTriggerItem(dataItem); + }, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js b/packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js new file mode 100644 index 0000000..9896f7b --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-n-minutes/index.js @@ -0,0 +1,131 @@ +import { DateTime } from 'luxon'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import cronTimes from '../../common/cron-times.js'; +import getNextCronDateTime from '../../common/get-next-cron-date-time.js'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object.js'; + +export default defineTrigger({ + name: 'Every N minutes', + key: 'everyNMinutes', + description: 'Triggers every N minutes.', + arguments: [ + { + label: 'Trigger on weekends?', + key: 'triggersOnWeekend', + type: 'dropdown', + description: 'Should this flow trigger on Saturday and Sunday?', + required: true, + value: true, + variables: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + { + label: 'Interval', + key: 'interval', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { label: 'Every 1 minute', value: 1 }, + { label: 'Every 2 minutes', value: 2 }, + { label: 'Every 3 minutes', value: 3 }, + { label: 'Every 4 minutes', value: 4 }, + { label: 'Every 5 minutes', value: 5 }, + { label: 'Every 6 minutes', value: 6 }, + { label: 'Every 7 minutes', value: 7 }, + { label: 'Every 8 minutes', value: 8 }, + { label: 'Every 9 minutes', value: 9 }, + { label: 'Every 10 minutes', value: 10 }, + { label: 'Every 11 minutes', value: 11 }, + { label: 'Every 12 minutes', value: 12 }, + { label: 'Every 13 minutes', value: 13 }, + { label: 'Every 14 minutes', value: 14 }, + { label: 'Every 15 minutes', value: 15 }, + { label: 'Every 16 minutes', value: 16 }, + { label: 'Every 17 minutes', value: 17 }, + { label: 'Every 18 minutes', value: 18 }, + { label: 'Every 19 minutes', value: 19 }, + { label: 'Every 20 minutes', value: 20 }, + { label: 'Every 21 minutes', value: 21 }, + { label: 'Every 22 minutes', value: 22 }, + { label: 'Every 23 minutes', value: 23 }, + { label: 'Every 24 minutes', value: 24 }, + { label: 'Every 25 minutes', value: 25 }, + { label: 'Every 26 minutes', value: 26 }, + { label: 'Every 27 minutes', value: 27 }, + { label: 'Every 28 minutes', value: 28 }, + { label: 'Every 29 minutes', value: 29 }, + { label: 'Every 30 minutes', value: 30 }, + { label: 'Every 31 minutes', value: 31 }, + { label: 'Every 32 minutes', value: 32 }, + { label: 'Every 33 minutes', value: 33 }, + { label: 'Every 34 minutes', value: 34 }, + { label: 'Every 35 minutes', value: 35 }, + { label: 'Every 36 minutes', value: 36 }, + { label: 'Every 37 minutes', value: 37 }, + { label: 'Every 38 minutes', value: 38 }, + { label: 'Every 39 minutes', value: 39 }, + { label: 'Every 40 minutes', value: 40 }, + { label: 'Every 41 minutes', value: 41 }, + { label: 'Every 42 minutes', value: 42 }, + { label: 'Every 43 minutes', value: 43 }, + { label: 'Every 44 minutes', value: 44 }, + { label: 'Every 45 minutes', value: 45 }, + { label: 'Every 46 minutes', value: 46 }, + { label: 'Every 47 minutes', value: 47 }, + { label: 'Every 48 minutes', value: 48 }, + { label: 'Every 49 minutes', value: 49 }, + { label: 'Every 50 minutes', value: 50 }, + { label: 'Every 51 minutes', value: 51 }, + { label: 'Every 52 minutes', value: 52 }, + { label: 'Every 53 minutes', value: 53 }, + { label: 'Every 54 minutes', value: 54 }, + { label: 'Every 55 minutes', value: 55 }, + { label: 'Every 56 minutes', value: 56 }, + { label: 'Every 57 minutes', value: 57 }, + { label: 'Every 58 minutes', value: 58 }, + { label: 'Every 59 minutes', value: 59 }, + ], + }, + ], + + getInterval(parameters) { + if (parameters.triggersOnWeekend) { + return cronTimes.everyNMinutes(parameters.interval); + } + + return cronTimes.everyNMinutesExcludingWeekends(parameters.interval); + }, + + async run($) { + const nextCronDateTime = getNextCronDateTime( + this.getInterval($.step.parameters) + ); + + const dateTime = DateTime.now(); + + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation( + $.execution.testRun ? nextCronDateTime : dateTime + ); + + const dataItem = { + raw: dateTimeObjectRepresentation, + meta: { + internalId: dateTime.toMillis().toString(), + }, + }; + + $.pushTriggerItem(dataItem); + }, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/every-week/index.js b/packages/backend/src/apps/scheduler/triggers/every-week/index.js new file mode 100644 index 0000000..9ee925a --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-week/index.js @@ -0,0 +1,186 @@ +import { DateTime } from 'luxon'; + +import defineTrigger from '../../../../helpers/define-trigger.js'; +import cronTimes from '../../common/cron-times.js'; +import getNextCronDateTime from '../../common/get-next-cron-date-time.js'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object.js'; + +export default defineTrigger({ + name: 'Every week', + key: 'everyWeek', + description: 'Triggers every week.', + arguments: [ + { + label: 'Day of the week', + key: 'weekday', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: 'Monday', + value: 1, + }, + { + label: 'Tuesday', + value: 2, + }, + { + label: 'Wednesday', + value: 3, + }, + { + label: 'Thursday', + value: 4, + }, + { + label: 'Friday', + value: 5, + }, + { + label: 'Saturday', + value: 6, + }, + { + label: 'Sunday', + value: 0, + }, + ], + }, + { + label: 'Time of day', + key: 'hour', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '00:00', + value: 0, + }, + { + label: '01:00', + value: 1, + }, + { + label: '02:00', + value: 2, + }, + { + label: '03:00', + value: 3, + }, + { + label: '04:00', + value: 4, + }, + { + label: '05:00', + value: 5, + }, + { + label: '06:00', + value: 6, + }, + { + label: '07:00', + value: 7, + }, + { + label: '08:00', + value: 8, + }, + { + label: '09:00', + value: 9, + }, + { + label: '10:00', + value: 10, + }, + { + label: '11:00', + value: 11, + }, + { + label: '12:00', + value: 12, + }, + { + label: '13:00', + value: 13, + }, + { + label: '14:00', + value: 14, + }, + { + label: '15:00', + value: 15, + }, + { + label: '16:00', + value: 16, + }, + { + label: '17:00', + value: 17, + }, + { + label: '18:00', + value: 18, + }, + { + label: '19:00', + value: 19, + }, + { + label: '20:00', + value: 20, + }, + { + label: '21:00', + value: 21, + }, + { + label: '22:00', + value: 22, + }, + { + label: '23:00', + value: 23, + }, + ], + }, + ], + + getInterval(parameters) { + const interval = cronTimes.everyWeekOnAndAt( + parameters.weekday, + parameters.hour + ); + + return interval; + }, + + async run($) { + const nextCronDateTime = getNextCronDateTime( + this.getInterval($.step.parameters) + ); + const dateTime = DateTime.now(); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation( + $.execution.testRun ? nextCronDateTime : dateTime + ); + + const dataItem = { + raw: dateTimeObjectRepresentation, + meta: { + internalId: dateTime.toMillis().toString(), + }, + }; + + $.pushTriggerItem(dataItem); + }, +}); diff --git a/packages/backend/src/apps/scheduler/triggers/index.js b/packages/backend/src/apps/scheduler/triggers/index.js new file mode 100644 index 0000000..cfdfac4 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/index.js @@ -0,0 +1,7 @@ +import everyNMinutes from './every-n-minutes/index.js'; +import everyHour from './every-hour/index.js'; +import everyDay from './every-day/index.js'; +import everyWeek from './every-week/index.js'; +import everyMonth from './every-month/index.js'; + +export default [everyNMinutes, everyHour, everyDay, everyWeek, everyMonth]; diff --git a/packages/backend/src/apps/self-hosted-llm/actions/index.js b/packages/backend/src/apps/self-hosted-llm/actions/index.js new file mode 100644 index 0000000..42d0ac8 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/actions/index.js @@ -0,0 +1,4 @@ +import sendPrompt from './send-prompt/index.js'; +import sendChatPrompt from './send-chat-prompt/index.js'; + +export default [sendChatPrompt, sendPrompt]; diff --git a/packages/backend/src/apps/self-hosted-llm/actions/send-chat-prompt/index.js b/packages/backend/src/apps/self-hosted-llm/actions/send-chat-prompt/index.js new file mode 100644 index 0000000..2865a69 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/actions/send-chat-prompt/index.js @@ -0,0 +1,138 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send chat prompt', + key: 'sendChatPrompt', + description: 'Creates a completion for the provided prompt and parameters.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'Add or remove messages as needed', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Stop Sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + description: + 'Single stop sequence where the API will stop generating further tokens. The returned text will not contain the stop sequence.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with Top P probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.`, + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: $.step.parameters.stopSequence || null, + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + messages: $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + }; + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/self-hosted-llm/actions/send-prompt/index.js b/packages/backend/src/apps/self-hosted-llm/actions/send-prompt/index.js new file mode 100644 index 0000000..786b81e --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/actions/send-prompt/index.js @@ -0,0 +1,110 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Send prompt', + key: 'sendPrompt', + description: 'Creates a completion for the provided prompt and parameters.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Prompt', + key: 'prompt', + type: 'string', + required: true, + variables: true, + description: 'The text to analyze.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'What sampling temperature to use. Higher values mean the model will take more risk. Try 0.9 for more creative applications, and 0 for ones with a well-defined answer. We generally recommend altering this or Top P but not both.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: + 'The maximum number of tokens to generate in the completion.', + }, + { + label: 'Stop Sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + description: + 'Single stop sequence where the API will stop generating further tokens. The returned text will not contain the stop sequence.', + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: + 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with Top P probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.', + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.`, + }, + ], + + async run($) { + const payload = { + model: $.step.parameters.model, + prompt: $.step.parameters.prompt, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: $.step.parameters.stopSequence || null, + top_p: castFloatOrUndefined($.step.parameters.topP), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + }; + const { data } = await $.http.post('/v1/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/self-hosted-llm/assets/favicon.svg b/packages/backend/src/apps/self-hosted-llm/assets/favicon.svg new file mode 100644 index 0000000..b62b84e --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/assets/favicon.svg @@ -0,0 +1,6 @@ + + OpenAI + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/self-hosted-llm/auth/index.js b/packages/backend/src/apps/self-hosted-llm/auth/index.js new file mode 100644 index 0000000..9d48053 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/auth/index.js @@ -0,0 +1,44 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiUrl', + label: 'API URL', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + docUrl: 'https://automatisch.io/docs/self-hosted-llm#api-url', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + docUrl: 'https://automatisch.io/docs/self-hosted-llm#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/self-hosted-llm/auth/is-still-verified.js b/packages/backend/src/apps/self-hosted-llm/auth/is-still-verified.js new file mode 100644 index 0000000..3e6c909 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/self-hosted-llm/auth/verify-credentials.js b/packages/backend/src/apps/self-hosted-llm/auth/verify-credentials.js new file mode 100644 index 0000000..7f43f88 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/self-hosted-llm/common/add-auth-header.js b/packages/backend/src/apps/self-hosted-llm/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/self-hosted-llm/common/set-base-url.js b/packages/backend/src/apps/self-hosted-llm/common/set-base-url.js new file mode 100644 index 0000000..4dd124a --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/common/set-base-url.js @@ -0,0 +1,9 @@ +const setBaseUrl = ($, requestConfig) => { + if ($.auth.data.apiUrl) { + requestConfig.baseURL = $.auth.data.apiUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/self-hosted-llm/dynamic-data/index.js b/packages/backend/src/apps/self-hosted-llm/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/self-hosted-llm/dynamic-data/list-models/index.js b/packages/backend/src/apps/self-hosted-llm/dynamic-data/list-models/index.js new file mode 100644 index 0000000..a8e8153 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const response = await $.http.get('/v1/models'); + + const models = response.data.data.map((model) => { + return { + value: model.id, + name: model.id, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/self-hosted-llm/index.js b/packages/backend/src/apps/self-hosted-llm/index.js new file mode 100644 index 0000000..4b05ab8 --- /dev/null +++ b/packages/backend/src/apps/self-hosted-llm/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Self-hosted LLM', + key: 'self-hosted-llm', + baseUrl: '', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/self-hosted-llm/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/self-hosted-llm/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js b/packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js new file mode 100644 index 0000000..5055a64 --- /dev/null +++ b/packages/backend/src/apps/signalwire/actions/add-voice-xml-node/index.js @@ -0,0 +1,169 @@ +import { XMLBuilder } from 'fast-xml-parser'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Add voice XML node', + key: 'addVoiceXmlNode', + description: 'Add a voice XML node in the XML document', + supportsConnections: false, + arguments: [ + { + label: 'Node name', + key: 'nodeName', + type: 'dropdown', + required: true, + description: 'The name of the node to be added.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodes', + }, + ], + }, + }, + { + label: 'Node value', + key: 'nodeValue', + type: 'string', + required: false, + description: 'The value of the node to be added.', + variables: true, + }, + { + label: 'Attributes', + key: 'attributes', + type: 'dynamic', + required: false, + description: 'Add or remove attributes for the node as needed', + value: [ + { + key: '', + value: '', + }, + ], + fields: [ + { + label: 'Attribute name', + key: 'key', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributes', + }, + { + name: 'parameters.nodeName', + value: '{parameters.nodeName}', + }, + ], + }, + }, + { + label: 'Attribute value', + key: 'value', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributeValues', + }, + { + name: 'parameters.nodeName', + value: '{parameters.nodeName}', + }, + { + name: 'parameters.attributeKey', + value: '{fieldsScope.key}', + }, + ], + }, + }, + ], + }, + { + label: 'Add children node', + key: 'hasChildrenNodes', + type: 'dropdown', + required: true, + description: 'Add a nested node to the main node', + value: false, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listNodeFields', + }, + { + name: 'parameters.hasChildrenNodes', + value: '{parameters.hasChildrenNodes}', + }, + ], + }, + }, + ], + + async run($) { + const nodeName = $.step.parameters.nodeName; + const nodeValue = $.step.parameters.nodeValue; + const attributes = $.step.parameters.attributes; + const childrenNodes = $.step.parameters.childrenNodes; + const hasChildrenNodes = $.step.parameters.hasChildrenNodes; + + const builder = new XMLBuilder({ + ignoreAttributes: false, + suppressEmptyNode: true, + preserveOrder: true, + }); + + const computeAttributes = (attributes) => + attributes + .filter((attribute) => attribute.key || attribute.value) + .reduce( + (result, attribute) => ({ + ...result, + [`@_${attribute.key}`]: attribute.value, + }), + {} + ); + + const computeTextNode = (nodeValue) => ({ + '#text': nodeValue, + }); + + const computedChildrenNodes = hasChildrenNodes + ? childrenNodes.map((childNode) => ({ + [childNode.nodeName]: [computeTextNode(childNode.nodeValue)], + ':@': computeAttributes(childNode.attributes), + })) + : []; + + const xmlObject = { + [nodeName]: [computeTextNode(nodeValue), ...computedChildrenNodes], + ':@': computeAttributes(attributes), + }; + + const xmlString = builder.build([xmlObject]); + + $.setActionItem({ raw: { stringNode: xmlString } }); + }, +}); diff --git a/packages/backend/src/apps/signalwire/actions/index.js b/packages/backend/src/apps/signalwire/actions/index.js new file mode 100644 index 0000000..dc2fe64 --- /dev/null +++ b/packages/backend/src/apps/signalwire/actions/index.js @@ -0,0 +1,5 @@ +import sendSms from './send-sms/index.js'; +import addVoiceXmlNode from './add-voice-xml-node/index.js'; +import respondWithVoiceXml from './respond-with-voice-xml/index.js'; + +export default [addVoiceXmlNode, respondWithVoiceXml, sendSms]; diff --git a/packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js b/packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js new file mode 100644 index 0000000..d8bb76d --- /dev/null +++ b/packages/backend/src/apps/signalwire/actions/respond-with-voice-xml/index.js @@ -0,0 +1,66 @@ +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Respond with voice XML', + key: 'respondWithVoiceXml', + description: 'Respond with defined voice XML document', + supportsConnections: false, + arguments: [ + { + label: 'Nodes', + key: 'nodes', + type: 'dynamic', + required: false, + description: 'Add or remove nodes for the XML document as needed', + value: [ + { + nodeString: '', + }, + ], + fields: [ + { + label: 'Node', + key: 'nodeString', + type: 'string', + required: true, + variables: true, + }, + ], + }, + ], + + async run($) { + const builder = new XMLBuilder({ + ignoreAttributes: false, + suppressEmptyNode: true, + preserveOrder: true, + }); + + const parser = new XMLParser({ + ignoreAttributes: false, + preserveOrder: true, + parseTagValue: false, + }); + + const nodes = $.step.parameters.nodes; + const computedNodes = nodes.map((node) => node.nodeString); + const parsedNodes = computedNodes.flatMap((computedNode) => + parser.parse(computedNode) + ); + + const xmlString = builder.build([ + { + Response: parsedNodes, + }, + ]); + + $.setActionItem({ + raw: { + body: xmlString, + statusCode: 200, + headers: { 'content-type': 'text/xml' }, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/signalwire/actions/send-sms/index.js b/packages/backend/src/apps/signalwire/actions/send-sms/index.js new file mode 100644 index 0000000..3c7487a --- /dev/null +++ b/packages/backend/src/apps/signalwire/actions/send-sms/index.js @@ -0,0 +1,63 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send an SMS', + key: 'sendSms', + description: 'Sends an SMS', + arguments: [ + { + label: 'From Number', + key: 'fromNumber', + type: 'dropdown', + required: true, + description: + 'The number to send the SMS from. Include only country code. Example: 491234567890', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listIncomingSmsPhoneNumbers', + }, + ], + }, + }, + { + label: 'To Number', + key: 'toNumber', + type: 'string', + required: true, + description: + 'The number to send the SMS to. Include only country code. Example: 491234567890', + variables: true, + }, + { + label: 'Message', + key: 'message', + type: 'string', + required: true, + description: 'The content of the message.', + variables: true, + }, + ], + + async run($) { + const requestPath = `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}/Messages`; + + const Body = $.step.parameters.message; + const From = $.step.parameters.fromNumber; + const To = '+' + $.step.parameters.toNumber.trim(); + + const response = await $.http.post(requestPath, null, { + params: { + Body, + From, + To, + }, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/signalwire/assets/favicon.svg b/packages/backend/src/apps/signalwire/assets/favicon.svg new file mode 100644 index 0000000..1dda203 --- /dev/null +++ b/packages/backend/src/apps/signalwire/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/signalwire/auth/index.js b/packages/backend/src/apps/signalwire/auth/index.js new file mode 100644 index 0000000..59a32be --- /dev/null +++ b/packages/backend/src/apps/signalwire/auth/index.js @@ -0,0 +1,64 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'accountSid', + label: 'Project ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Log into your SignalWire account and find the Project ID', + clickToCopy: false, + }, + { + key: 'authToken', + label: 'API Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API Token in the respective project', + clickToCopy: false, + }, + { + key: 'spaceRegion', + label: 'SignalWire Region', + type: 'dropdown', + required: true, + readOnly: false, + value: '', + placeholder: null, + description: 'Most people should choose the default, "US"', + clickToCopy: false, + options: [ + { + label: 'US', + value: '', + }, + { + label: 'EU', + value: 'eu-', + }, + ], + }, + { + key: 'spaceName', + label: 'Space Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Name of your SignalWire space that contains the project', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/signalwire/auth/is-still-verified.js b/packages/backend/src/apps/signalwire/auth/is-still-verified.js new file mode 100644 index 0000000..270d415 --- /dev/null +++ b/packages/backend/src/apps/signalwire/auth/is-still-verified.js @@ -0,0 +1,9 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/signalwire/auth/verify-credentials.js b/packages/backend/src/apps/signalwire/auth/verify-credentials.js new file mode 100644 index 0000000..4b87b86 --- /dev/null +++ b/packages/backend/src/apps/signalwire/auth/verify-credentials.js @@ -0,0 +1,11 @@ +const verifyCredentials = async ($) => { + const { data } = await $.http.get( + `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}` + ); + + await $.auth.set({ + screenName: `${data.friendly_name} (${$.auth.data.accountSid})`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/signalwire/common/add-auth-header.js b/packages/backend/src/apps/signalwire/common/add-auth-header.js new file mode 100644 index 0000000..7059a8c --- /dev/null +++ b/packages/backend/src/apps/signalwire/common/add-auth-header.js @@ -0,0 +1,22 @@ +const addAuthHeader = ($, requestConfig) => { + const authData = $.auth.data || {}; + + requestConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + if (authData.accountSid && authData.authToken) { + requestConfig.auth = { + username: authData.accountSid, + password: authData.authToken, + }; + } + + if (authData.spaceName) { + const serverUrl = `https://${authData.spaceName}.${authData.spaceRegion}signalwire.com`; + + requestConfig.baseURL = serverUrl; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/index.js b/packages/backend/src/apps/signalwire/dynamic-data/index.js new file mode 100644 index 0000000..fc077d2 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/index.js @@ -0,0 +1,15 @@ +import listIncomingCallPhoneNumbers from './list-incoming-call-phone-numbers/index.js'; +import listIncomingSmsPhoneNumbers from './list-incoming-sms-phone-numbers/index.js'; +import listVoiceXmlNodeAttributes from './list-voice-xml-node-attributes/index.js'; +import listVoiceXmlNodeAttributeValues from './list-voice-xml-node-attribute-values/index.js'; +import listVoiceXmlChildrenNodes from './list-voice-xml-children-nodes/index.js'; +import listVoiceXmlNodes from './list-voice-xml-nodes/index.js'; + +export default [ + listIncomingCallPhoneNumbers, + listIncomingSmsPhoneNumbers, + listVoiceXmlNodeAttributes, + listVoiceXmlNodeAttributeValues, + listVoiceXmlNodes, + listVoiceXmlChildrenNodes, +]; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js new file mode 100644 index 0000000..93f3ded --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-call-phone-numbers/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List incoming call phone numbers', + key: 'listIncomingCallPhoneNumbers', + + async run($) { + let requestPath = `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers`; + + const aggregatedResponse = { + data: [], + }; + + do { + const { data } = await $.http.get(requestPath); + + const voiceCapableIncomingPhoneNumbers = data.incoming_phone_numbers + .filter((incomingPhoneNumber) => { + return incomingPhoneNumber.capabilities.voice; + }) + .map((incomingPhoneNumber) => { + const friendlyName = incomingPhoneNumber.friendly_name; + const phoneNumber = incomingPhoneNumber.phone_number; + const name = [friendlyName, phoneNumber].filter(Boolean).join(' - '); + + return { + value: incomingPhoneNumber.sid, + name, + }; + }); + + aggregatedResponse.data.push(...voiceCapableIncomingPhoneNumbers); + + requestPath = data.next_page_uri; + } while (requestPath); + + return aggregatedResponse; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js new file mode 100644 index 0000000..9a8129a --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-incoming-sms-phone-numbers/index.js @@ -0,0 +1,36 @@ +export default { + name: 'List incoming SMS phone numbers', + key: 'listIncomingSmsPhoneNumbers', + + async run($) { + let requestPath = `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers`; + + const aggregatedResponse = { + data: [], + }; + + do { + const { data } = await $.http.get(requestPath); + + const smsCapableIncomingPhoneNumbers = data.incoming_phone_numbers + .filter((incomingPhoneNumber) => { + return incomingPhoneNumber.capabilities.sms; + }) + .map((incomingPhoneNumber) => { + const friendlyName = incomingPhoneNumber.friendly_name; + const phoneNumber = incomingPhoneNumber.phone_number; + const name = [friendlyName, phoneNumber].filter(Boolean).join(' - '); + + return { + value: phoneNumber, + name, + }; + }); + aggregatedResponse.data.push(...smsCapableIncomingPhoneNumbers); + + requestPath = data.next_page_uri; + } while (requestPath); + + return aggregatedResponse; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js new file mode 100644 index 0000000..a05947a --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-children-nodes/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List voice XML children nodes', + key: 'listVoiceXmlChildrenNodes', + + async run($) { + const parentNodeName = $.step.parameters.parentNodeName; + + const parentChildrenNodeMap = { + Dial: [ + { name: 'Number', value: 'Number' }, + { name: 'Conference', value: 'Conference' }, + { name: 'Queue', value: 'Queue' }, + { name: 'Sip', value: 'Sip' }, + { name: 'Verto', value: 'Verto' }, + ], + Gather: [ + { name: 'Say', value: 'Say' }, + { name: 'Play', value: 'Play' }, + { name: 'Pause', value: 'Pause' }, + ], + Refer: [{ name: 'Sip', value: 'Sip' }], + Connect: [ + { name: 'Room', value: 'Room' }, + { name: 'Stream', value: 'Stream' }, + { name: 'VirtualAgent', value: 'VirtualAgent' }, + ], + }; + + const childrenNodes = parentChildrenNodeMap[parentNodeName] || []; + + const nodes = { + data: childrenNodes, + }; + + return nodes; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js new file mode 100644 index 0000000..cfbf314 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attribute-values/index.js @@ -0,0 +1,516 @@ +export default { + name: 'List voice XML node attribute values', + key: 'listVoiceXmlNodeAttributeValues', + + async run($) { + const nodeName = $.step.parameters.nodeName; + const attributeKey = $.step.parameters.attributeKey; + + // Node: Conference + const conferenceMutedAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + ]; + + const conferenceBeepAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + { + name: 'On Enter Only', + value: 'onEnter', + }, + { + name: 'On Exit Only', + value: 'onExit', + }, + ]; + + const conferenceStartConferenceOnEnterAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + ]; + + const conferenceEndConferenceOnExitAttributeValues = [ + { + name: 'Yes', + value: true, + }, + { + name: 'No', + value: false, + }, + ]; + + const conferenceWaitMethodAttributeValues = [ + { + name: 'POST', + value: 'POST', + }, + { + name: 'GET', + value: 'GET', + }, + ]; + + const conferenceRecordAttributeValues = [ + { + name: 'Record From Start', + value: 'record-from-start', + }, + { + name: 'Do Not Record', + value: 'do-not-record', + }, + ]; + + const conferenceTrimAttributeValues = [ + { + name: 'Trim Silence', + value: 'trim-silence', + }, + { + name: 'Do Not Trim', + value: 'do-not-trim', + }, + ]; + + const conferenceJitterBufferAttributeValues = [ + { + name: 'Off', + value: 'off', + }, + { + name: 'Fixed', + value: 'fixed', + }, + { + name: 'Adaptive', + value: 'adaptive', + }, + ]; + + const conference = { + muted: conferenceMutedAttributeValues, + beep: conferenceBeepAttributeValues, + startConferenceOnEnter: conferenceStartConferenceOnEnterAttributeValues, + endConferenceOnExit: conferenceEndConferenceOnExitAttributeValues, + waitMethod: conferenceWaitMethodAttributeValues, + record: conferenceRecordAttributeValues, + trim: conferenceTrimAttributeValues, + jitterBuffer: conferenceJitterBufferAttributeValues, + }; + + // NODE: Say + const sayVoiceAttributeValues = [ + { name: 'Man', value: 'man' }, + { name: 'Woman', value: 'woman' }, + { name: 'Polly Man', value: 'Polly.man' }, + { name: 'Polly Woman', value: 'Polly.woman' }, + { name: 'Polly Man Neural', value: 'Polly.man-Neural' }, + { name: 'Polly Woman Neural', value: 'Polly.woman-Neural' }, + { name: 'Google Cloud Man', value: 'gcloud.man' }, + { name: 'Google Cloud Woman', value: 'gcloud.woman' }, + ]; + + const sayLoopAttributeValues = [ + { name: 'Infinite', value: 0 }, + { name: 'One Time', value: 1 }, + { name: 'Two Times', value: 2 }, + { name: 'Three Times', value: 3 }, + { name: 'Four Times', value: 4 }, + { name: 'Five Times', value: 5 }, + ]; + + const sayLanguageAttributeValues = [ + { name: 'English (US)', value: 'en-US' }, + { name: 'English (UK)', value: 'en-GB' }, + { name: 'Spanish (Spain)', value: 'es-ES' }, + { name: 'French (France)', value: 'fr-FR' }, + { name: 'German (Germany)', value: 'de-DE' }, + ]; + + const say = { + voice: sayVoiceAttributeValues, + loop: sayLoopAttributeValues, + language: sayLanguageAttributeValues, + }; + + // Node: Sip + + const sipCodecsAttributeValues = [ + { name: 'PCMU', value: 'PCMU' }, + { name: 'PCMA', value: 'PCMA' }, + { name: 'G722', value: 'G722' }, + { name: 'G729', value: 'G729' }, + { name: 'OPUS', value: 'OPUS' }, + ]; + + const sipMethodAttributeValues = [ + { name: 'GET', value: 'GET' }, + { name: 'POST', value: 'POST' }, + ]; + + const sipStatusCallbackMethodAttributeValues = [ + { name: 'GET', value: 'GET' }, + { name: 'POST', value: 'POST' }, + ]; + + const sipStatusCallbackEventValues = [ + { name: 'Initiated', value: 'initiated' }, + { name: 'Ringing', value: 'ringing' }, + { name: 'Answered', value: 'answered' }, + { name: 'Completed', value: 'completed' }, + ]; + + const sip = { + codecs: sipCodecsAttributeValues, + method: sipMethodAttributeValues, + statusCallbackMethod: sipStatusCallbackMethodAttributeValues, + statusCallbackEvent: sipStatusCallbackEventValues, + }; + + // Node: Stream + const streamTrackAttributeValues = [ + { + name: 'Inbound Track', + value: 'inbound_track', + }, + { + name: 'Outbound Track', + value: 'outbound_track', + }, + { + name: 'Both Tracks', + value: 'both_tracks', + }, + ]; + + const streamStatusCallbackMethodAttributeValues = [ + { + name: 'GET', + value: 'GET', + }, + { + name: 'POST', + value: 'POST', + }, + ]; + + const stream = { + track: streamTrackAttributeValues, + statusCallbackMethod: streamStatusCallbackMethodAttributeValues, + }; + + // Node: Dial + const dialAnswerOnBridgeAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const dialHangupOnStarAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const dialMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const dialRecordAttributeValues = [ + { name: 'Do Not Record', value: 'do-not-record' }, + { name: 'Record from Answer', value: 'record-from-answer' }, + { name: 'Record from Ringing', value: 'record-from-ringing' }, + { name: 'Dual Channel from Answer', value: 'record-from-answer-dual' }, + { name: 'Dual Channel from Ringing', value: 'record-from-ringing-dual' }, + ]; + + const dialRecordingStatusCallbackEventAttributeValues = [ + { name: 'Completed', value: 'completed' }, + { name: 'In Progress', value: 'in-progress' }, + { name: 'Absent', value: 'absent' }, + ]; + + const dialRecordingStatusCallbackMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const dialRecordingTrackAttributeValues = [ + { name: 'Inbound', value: 'inbound' }, + { name: 'Outbound', value: 'outbound' }, + { name: 'Both', value: 'both' }, + ]; + + const dialRingToneAttributeValues = [ + { name: 'Austria', value: 'at' }, + { name: 'Australia', value: 'au' }, + { name: 'Belgium', value: 'be' }, + { name: 'Brazil', value: 'br' }, + { name: 'Canada', value: 'ca' }, + { name: 'China', value: 'cn' }, + { name: 'Denmark', value: 'dk' }, + { name: 'France', value: 'fr' }, + { name: 'Germany', value: 'de' }, + { name: 'United States', value: 'us' }, + { name: 'United Kingdom', value: 'uk' }, + { name: 'Japan', value: 'jp' }, + // Add more ISO 3166-1 alpha-2 codes as needed + ]; + + const dialTrimAttributeValues = [ + { name: 'Trim Silence', value: 'trim-silence' }, + { name: 'Do Not Trim', value: 'do-not-trim' }, + ]; + + const dial = { + answerOnBridge: dialAnswerOnBridgeAttributeValues, + hangupOnStar: dialHangupOnStarAttributeValues, + method: dialMethodAttributeValues, + record: dialRecordAttributeValues, + recordingStatusCallbackEvent: + dialRecordingStatusCallbackEventAttributeValues, + recordingStatusCallbackMethod: + dialRecordingStatusCallbackMethodAttributeValues, + recordingTrack: dialRecordingTrackAttributeValues, + ringTone: dialRingToneAttributeValues, + trim: dialTrimAttributeValues, + }; + + // Node: Enqueue + const enqueueMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const enqueueWaitUrlMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const enqueue = { + method: enqueueMethodAttributeValues, + waitUrlMethod: enqueueWaitUrlMethodAttributeValues, + }; + + // Node: Gather + const gatherActionOnEmptyResultAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const gatherEnhancedAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const gatherInputAttributeValues = [ + { name: 'DTMF', value: 'dtmf' }, + { name: 'Speech', value: 'speech' }, + { name: 'DTMF and Speech', value: 'dtmf speech' }, + ]; + + const gatherLanguageAttributeValues = [ + { name: 'English (US)', value: 'en-US' }, + { name: 'English (UK)', value: 'en-GB' }, + { name: 'Spanish (Spain)', value: 'es-ES' }, + { name: 'French (France)', value: 'fr-FR' }, + { name: 'German (Germany)', value: 'de-DE' }, + ]; + + const gatherMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const gatherProfanityFilterAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const gatherSpeechModelAttributeValues = [ + { name: 'Phone Call', value: 'phone_call' }, + { name: 'Video', value: 'video' }, + { name: 'Default', value: 'default' }, + ]; + + const gatherSpeechTimeoutAttributeValues = [ + { name: 'Auto', value: 'auto' }, + ]; + + const gather = { + actionOnEmptyResult: gatherActionOnEmptyResultAttributeValues, + enhanced: gatherEnhancedAttributeValues, + input: gatherInputAttributeValues, + language: gatherLanguageAttributeValues, + method: gatherMethodAttributeValues, + profanityFilter: gatherProfanityFilterAttributeValues, + speechModel: gatherSpeechModelAttributeValues, + speechTimeout: gatherSpeechTimeoutAttributeValues, + }; + + // Node: Number + const numberMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const numberStatusCallbackEventAttributeValues = [ + { name: 'Initiated', value: 'initiated' }, + { name: 'Ringing', value: 'ringing' }, + { name: 'Answered', value: 'answered' }, + { name: 'Completed', value: 'completed' }, + ]; + + const number = { + method: numberMethodAttributeValues, + statusCallbackEvent: numberStatusCallbackEventAttributeValues, + }; + + // Node: Queue + const queueMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const queue = { + method: queueMethodAttributeValues, + }; + + // Node: Record + const recordMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const recordPlayBeepAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const recordTrimAttributeValues = [ + { name: 'Trim Silence', value: 'trim-silence' }, + { name: 'Do Not Trim', value: 'do-not-trim' }, + ]; + + const recordRecordingStatusCallbackEventAttributeValues = [ + { name: 'Completed', value: 'completed' }, + { name: 'In Progress', value: 'in-progress' }, + { name: 'Absent', value: 'absent' }, + ]; + + const recordRecordingStatusCallbackMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const recordStorageUrlMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'PUT', value: 'PUT' }, + ]; + + const recordTranscribeAttributeValues = [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ]; + + const record = { + method: recordMethodAttributeValues, + playBeep: recordPlayBeepAttributeValues, + trim: recordTrimAttributeValues, + recordingStatusCallbackEvent: + recordRecordingStatusCallbackEventAttributeValues, + recordingStatusCallbackMethod: + recordRecordingStatusCallbackMethodAttributeValues, + storageUrlMethod: recordStorageUrlMethodAttributeValues, + transcribe: recordTranscribeAttributeValues, + }; + + // Node: Redirect + const redirectMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const redirect = { + method: redirectMethodAttributeValues, + }; + + // Node: Refer + const referMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const refer = { + method: referMethodAttributeValues, + }; + + // Node: Reject + const rejectReasonAttributeValues = [ + { name: 'Busy', value: 'busy' }, + { name: 'Rejected', value: 'rejected' }, + ]; + + const reject = { + reason: rejectReasonAttributeValues, + }; + + // Node: Sms + const smsMethodAttributeValues = [ + { name: 'POST', value: 'POST' }, + { name: 'GET', value: 'GET' }, + ]; + + const sms = { + method: smsMethodAttributeValues, + }; + + const allNodeAttributeValues = { + Conference: conference, + Dial: dial, + Enqueue: enqueue, + Gather: gather, + Number: number, + Queue: queue, + Record: record, + Redirect: redirect, + Refer: refer, + Reject: reject, + Say: say, + Sip: sip, + Sms: sms, + Stream: stream, + }; + + if (!nodeName) return { data: [] }; + + const selectedNodeAttributes = allNodeAttributeValues[nodeName]; + + if (!selectedNodeAttributes) return { data: [] }; + + const selectedNodeAttributeValues = selectedNodeAttributes[attributeKey]; + + if (!selectedNodeAttributeValues) return { data: [] }; + + return { data: selectedNodeAttributeValues }; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js new file mode 100644 index 0000000..1a646e8 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-node-attributes/index.js @@ -0,0 +1,205 @@ +export default { + name: 'List voice XML node attributes', + key: 'listVoiceXmlNodeAttributes', + + async run($) { + const nodeName = $.step.parameters.nodeName; + + const conferenceAttributes = [ + { name: 'Beep', value: 'beep' }, + { name: 'Coach', value: 'coach' }, + { name: 'End Conference On Exit', value: 'endConferenceOnExit' }, + { name: 'Event Callback URL', value: 'eventCallbackUrl' }, + { name: 'Max Participants', value: 'maxParticipants' }, + { name: 'Muted', value: 'muted' }, + { name: 'Record', value: 'record' }, + { + name: 'Recording Status Callback Event', + value: 'recordingStatusCallbackEvent', + }, + { + name: 'Recording Status Callback Method', + value: 'recordingStatusCallbackMethod', + }, + { name: 'Recording Status Callback', value: 'recordingStatusCallback' }, + { name: 'Start Conference On Enter', value: 'startConferenceOnEnter' }, + { name: 'Status Callback Event', value: 'statusCallbackEvent' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'Trim', value: 'trim' }, + { name: 'Wait Method', value: 'waitMethod' }, + { name: 'Wait URL', value: 'waitUrl' }, + ]; + + const dialAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Answer On Bridge', value: 'answerOnBridge' }, + { name: 'Caller ID', value: 'callerId' }, + { name: 'Caller Name', value: 'callerName' }, + { name: 'Hangup On Star', value: 'hangupOnStar' }, + { name: 'Method', value: 'method' }, + { name: 'Record', value: 'record' }, + { + name: 'Recording Status Callback Event', + value: 'recordingStatusCallbackEvent', + }, + { + name: 'Recording Status Callback Method', + value: 'recordingStatusCallbackMethod', + }, + { name: 'Recording Status Callback', value: 'recordingStatusCallback' }, + { + name: 'Recording Storage URL Method', + value: 'recordingStorageUrlMethod', + }, + { name: 'Recording Storage URL', value: 'recordingStorageUrl' }, + { name: 'Recording Track', value: 'recordingTrack' }, + { name: 'Ring Tone', value: 'ringTone' }, + { name: 'Time Limit', value: 'timeLimit' }, + { name: 'Timeout', value: 'timeout' }, + { name: 'Trim', value: 'trim' }, + ]; + + const echoAttributes = [{ name: 'Timeout', value: 'timeout' }]; + + const enqueueAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Method', value: 'method' }, + { name: 'Wait URL', value: 'waitUrl' }, + { name: 'Wait URL Method', value: 'waitUrlMethod' }, + ]; + + const gatherAttributes = [ + { name: 'Action On Empty Result', value: 'actionOnEmptyResult' }, + { name: 'Action', value: 'action' }, + { name: 'Enhanced', value: 'enhanced' }, + { name: 'Finish On Key', value: 'finishOnKey' }, + { name: 'Hints', value: 'hints' }, + { name: 'Input', value: 'input' }, + { name: 'Language', value: 'language' }, + { name: 'Method', value: 'method' }, + { name: 'Num Digits', value: 'numDigits' }, + { + name: 'Partial Result Callback Method', + value: 'partialResultCallbackMethod', + }, + { name: 'Partial Result Callback', value: 'partialResultCallback' }, + { name: 'Profanity Filter', value: 'profanityFilter' }, + { name: 'Speech Model', value: 'speechModel' }, + { name: 'Speech Timeout', value: 'speechTimeout' }, + { name: 'Timeout', value: 'timeout' }, + ]; + + const numberAttributes = [ + { name: 'Method', value: 'method' }, + { name: 'Send Digits', value: 'sendDigits' }, + { name: 'Status Callback Event', value: 'statusCallbackEvent' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'URL', value: 'url' }, + ]; + + const pauseAttributes = [{ name: 'Length', value: 'length' }]; + + const playAttributes = [ + { name: 'Digits', value: 'digits' }, + { name: 'Loop', value: 'loop' }, + ]; + + const queueAttributes = [ + { name: 'Method', value: 'method' }, + { name: 'URL', value: 'url' }, + ]; + + const recordAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Finish On Key', value: 'finishOnKey' }, + { name: 'Max Length', value: 'maxLength' }, + { name: 'Method', value: 'method' }, + { name: 'Play Beep', value: 'playBeep' }, + { + name: 'Recording Status Callback Event', + value: 'recordingStatusCallbackEvent', + }, + { + name: 'Recording Status Callback Method', + value: 'recordingStatusCallbackMethod', + }, + { name: 'Recording Status Callback', value: 'recordingStatusCallback' }, + { name: 'Storage URL Method', value: 'storageUrlMethod' }, + { name: 'Storage URL', value: 'storageUrl' }, + { name: 'Timeout', value: 'timeout' }, + { name: 'Transcribe Callback', value: 'transcribeCallback' }, + { name: 'Transcribe', value: 'transcribe' }, + { name: 'Trim', value: 'trim' }, + ]; + + const redirectAttributes = [{ name: 'Method', value: 'method' }]; + + const referAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'Method', value: 'method' }, + ]; + + const rejectAttributes = [{ name: 'Reason', value: 'reason' }]; + + const sayAttributes = [ + { name: 'Language', value: 'language' }, + { name: 'Loop', value: 'loop' }, + { name: 'Voice', value: 'voice' }, + ]; + + const sipAttributes = [ + { name: 'Codecs', value: 'codecs' }, + { name: 'Method', value: 'method' }, + { name: 'Password', value: 'password' }, + { name: 'Session Timeout', value: 'sessionTimeout' }, + { name: 'Status Callback Event', value: 'statusCallbackEvent' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'URL', value: 'url' }, + { name: 'Username', value: 'username' }, + ]; + + const smsAttributes = [ + { name: 'Action', value: 'action' }, + { name: 'From', value: 'from' }, + { name: 'Method', value: 'method' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'To', value: 'to' }, + ]; + + const virtualAgentAttributes = [ + { name: 'Connector Name', value: 'connectorName' }, + ]; + + const streamAttributes = [ + { name: 'URL', value: 'url' }, + { name: 'Name', value: 'name' }, + { name: 'Track', value: 'track' }, + { name: 'Status Callback', value: 'statusCallback' }, + { name: 'Status Callback Method', value: 'statusCallbackMethod' }, + ]; + + if (nodeName === 'Conference') return { data: conferenceAttributes }; + if (nodeName === 'Dial') return { data: dialAttributes }; + if (nodeName === 'Echo') return { data: echoAttributes }; + if (nodeName === 'Enqueue') return { data: enqueueAttributes }; + if (nodeName === 'Gather') return { data: gatherAttributes }; + if (nodeName === 'Number') return { data: numberAttributes }; + if (nodeName === 'Pause') return { data: pauseAttributes }; + if (nodeName === 'Play') return { data: playAttributes }; + if (nodeName === 'Queue') return { data: queueAttributes }; + if (nodeName === 'Record') return { data: recordAttributes }; + if (nodeName === 'Redirect') return { data: redirectAttributes }; + if (nodeName === 'Refer') return { data: referAttributes }; + if (nodeName === 'Reject') return { data: rejectAttributes }; + if (nodeName === 'Say') return { data: sayAttributes }; + if (nodeName === 'Sip') return { data: sipAttributes }; + if (nodeName === 'Sms') return { data: smsAttributes }; + if (nodeName === 'Stream') return { data: streamAttributes }; + if (nodeName === 'VirtualAgent') return { data: virtualAgentAttributes }; + + return { data: [] }; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js new file mode 100644 index 0000000..2ade44f --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-data/list-voice-xml-nodes/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List voice XML nodes', + key: 'listVoiceXmlNodes', + + async run() { + const nodes = { + data: [ + { name: 'Conference', value: 'Conference' }, + { name: 'Connect', value: 'Connect' }, + { name: 'Denoise', value: 'Denoise' }, + { name: 'Dial', value: 'Dial' }, + { name: 'Echo', value: 'Echo' }, + { name: 'Enqueue', value: 'Enqueue' }, + { name: 'Gather', value: 'Gather' }, + { name: 'Hangup', value: 'Hangup' }, + { name: 'Leave', value: 'Leave' }, + { name: 'Number', value: 'Number' }, + { name: 'Pause', value: 'Pause' }, + { name: 'Play', value: 'Play' }, + { name: 'Queue', value: 'Queue' }, + { name: 'Record', value: 'Record' }, + { name: 'Redirect', value: 'Redirect' }, + { name: 'Refer', value: 'Refer' }, + { name: 'Reject', value: 'Reject' }, + { name: 'Room', value: 'Room' }, + { name: 'Say', value: 'Say' }, + { name: 'Sip', value: 'Sip' }, + { name: 'Sms', value: 'Sms' }, + { name: 'Stream', value: 'Stream' }, + { name: 'Verto', value: 'Verto' }, + { name: 'VirtualAgent', value: 'VirtualAgent' }, + ], + }; + + return nodes; + }, +}; diff --git a/packages/backend/src/apps/signalwire/dynamic-fields/index.js b/packages/backend/src/apps/signalwire/dynamic-fields/index.js new file mode 100644 index 0000000..d04a966 --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listNodeFields from './list-node-fields/index.js'; + +export default [listNodeFields]; diff --git a/packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js b/packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js new file mode 100644 index 0000000..ecb102d --- /dev/null +++ b/packages/backend/src/apps/signalwire/dynamic-fields/list-node-fields/index.js @@ -0,0 +1,121 @@ +export default { + name: 'List node fields', + key: 'listNodeFields', + + async run($) { + const hasChildrenNodes = $.step.parameters.hasChildrenNodes; + + if (!hasChildrenNodes) { + return []; + } + + return [ + { + label: 'Children nodes', + key: 'childrenNodes', + type: 'dynamic', + required: false, + description: 'Add or remove nested node as needed', + value: [ + { + key: 'Content-Type', + value: 'application/json', + }, + ], + fields: [ + { + label: 'Node name', + key: 'nodeName', + type: 'dropdown', + required: false, + description: 'The name of the node to be added.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlChildrenNodes', + }, + { + name: 'parameters.parentNodeName', + value: '{parameters.nodeName}', + }, + ], + }, + }, + { + label: 'Node value', + key: 'nodeValue', + type: 'string', + required: false, + description: 'The value of the node to be added.', + variables: true, + }, + { + label: 'Attributes', + key: 'attributes', + type: 'dynamic', + required: false, + description: 'Add or remove attributes for the node as needed', + value: [ + { + key: '', + value: '', + }, + ], + fields: [ + { + label: 'Attribute name', + key: 'key', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributes', + }, + { + name: 'parameters.nodeName', + value: '{outerScope.nodeName}', + }, + ], + }, + }, + { + label: 'Attribute value', + key: 'value', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listVoiceXmlNodeAttributeValues', + }, + { + name: 'parameters.nodeName', + value: '{outerScope.nodeName}', + }, + { + name: 'parameters.attributeKey', + value: '{fieldsScope.key}', + }, + ], + }, + }, + ], + }, + ], + }, + ]; + }, +}; diff --git a/packages/backend/src/apps/signalwire/index.js b/packages/backend/src/apps/signalwire/index.js new file mode 100644 index 0000000..35b3076 --- /dev/null +++ b/packages/backend/src/apps/signalwire/index.js @@ -0,0 +1,24 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'SignalWire', + key: 'signalwire', + iconUrl: '{BASE_URL}/apps/signalwire/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/signalwire/connection', + supportsConnections: true, + baseUrl: 'https://signalwire.com', + apiBaseUrl: '', + primaryColor: '#044cf6', + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, + dynamicFields, +}); diff --git a/packages/backend/src/apps/signalwire/triggers/index.js b/packages/backend/src/apps/signalwire/triggers/index.js new file mode 100644 index 0000000..9bdc78b --- /dev/null +++ b/packages/backend/src/apps/signalwire/triggers/index.js @@ -0,0 +1,4 @@ +import receiveCall from './receive-call/index.js'; +import receiveSms from './receive-sms/index.js'; + +export default [receiveCall, receiveSms]; diff --git a/packages/backend/src/apps/signalwire/triggers/receive-call/index.js b/packages/backend/src/apps/signalwire/triggers/receive-call/index.js new file mode 100644 index 0000000..5ec5e0f --- /dev/null +++ b/packages/backend/src/apps/signalwire/triggers/receive-call/index.js @@ -0,0 +1,83 @@ +import { URLSearchParams } from 'node:url'; +import Crypto from 'node:crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Receive Call', + key: 'receiveCall', + workSynchronously: true, + type: 'webhook', + description: 'Triggers when a new call is received.', + arguments: [ + { + label: 'To Number', + key: 'phoneNumberSid', + type: 'dropdown', + required: true, + description: + 'The number to receive the call on. It should be a SignalWire number in your project.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listIncomingCallPhoneNumbers', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + + const payload = new URLSearchParams({ + VoiceUrl: $.webhookUrl, + }).toString(); + + await $.http.post( + `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`, + payload + ); + }, + + async unregisterHook($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + + const payload = new URLSearchParams({ + VoiceUrl: '', + }).toString(); + + await $.http.post( + `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`, + payload + ); + }, +}); diff --git a/packages/backend/src/apps/signalwire/triggers/receive-sms/fetch-messages.js b/packages/backend/src/apps/signalwire/triggers/receive-sms/fetch-messages.js new file mode 100644 index 0000000..ab3a6d4 --- /dev/null +++ b/packages/backend/src/apps/signalwire/triggers/receive-sms/fetch-messages.js @@ -0,0 +1,25 @@ +const fetchMessages = async ($) => { + const toNumber = $.step.parameters.toNumber; + + let response; + let requestPath = `/api/laml/2010-04-01/Accounts/${$.auth.data.accountSid}/Messages?To=${toNumber}`; + + do { + response = await $.http.get(requestPath); + + response.data.messages.forEach((message) => { + const dataItem = { + raw: message, + meta: { + internalId: message.date_sent, + }, + }; + + $.pushTriggerItem(dataItem); + }); + + requestPath = response.data.next_page_uri; + } while (requestPath); +}; + +export default fetchMessages; diff --git a/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js b/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js new file mode 100644 index 0000000..ee8465e --- /dev/null +++ b/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js @@ -0,0 +1,33 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import fetchMessages from './fetch-messages.js'; + +export default defineTrigger({ + name: 'Receive SMS', + key: 'receiveSms', + pollInterval: 15, + description: 'Triggers when a new SMS is received.', + arguments: [ + { + label: 'To Number', + key: 'toNumber', + type: 'dropdown', + required: true, + description: + 'The number to receive the SMS on. It should be a SignalWire number in your project.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listIncomingSmsPhoneNumbers', + }, + ], + }, + }, + ], + + async run($) { + await fetchMessages($); + }, +}); diff --git a/packages/backend/src/apps/slack/actions/find-message/find-message.js b/packages/backend/src/apps/slack/actions/find-message/find-message.js new file mode 100644 index 0000000..44de43b --- /dev/null +++ b/packages/backend/src/apps/slack/actions/find-message/find-message.js @@ -0,0 +1,22 @@ +const findMessage = async ($, options) => { + const params = { + query: options.query, + sort: options.sortBy, + sort_dir: options.sortDirection, + count: options.count || 1, + }; + + const response = await $.http.get('/search.messages', { + params, + }); + + const data = response.data; + + if (!data.ok && data) { + throw new Error(JSON.stringify(response.data)); + } + + $.setActionItem({ raw: data?.messages.matches[0] }); +}; + +export default findMessage; diff --git a/packages/backend/src/apps/slack/actions/find-message/index.js b/packages/backend/src/apps/slack/actions/find-message/index.js new file mode 100644 index 0000000..3a5c06a --- /dev/null +++ b/packages/backend/src/apps/slack/actions/find-message/index.js @@ -0,0 +1,76 @@ +import defineAction from '../../../../helpers/define-action.js'; +import findMessage from './find-message.js'; + +export default defineAction({ + name: 'Find a message', + key: 'findMessage', + description: 'Finds a message using the Slack feature.', + arguments: [ + { + label: 'Search Query', + key: 'query', + type: 'string', + required: true, + description: + 'Search query to use for finding matching messages. See the Slack Search Documentation for more information on constructing a query.', + variables: true, + }, + { + label: 'Sort by', + key: 'sortBy', + type: 'dropdown', + description: + 'Sort messages by their match strength or by their date. Default is score.', + required: true, + value: 'score', + variables: true, + options: [ + { + label: 'Match strength', + value: 'score', + }, + { + label: 'Message date time', + value: 'timestamp', + }, + ], + }, + { + label: 'Sort direction', + key: 'sortDirection', + type: 'dropdown', + description: + 'Sort matching messages in ascending or descending order. Default is descending.', + required: true, + value: 'desc', + variables: true, + options: [ + { + label: 'Descending (newest or best match first)', + value: 'desc', + }, + { + label: 'Ascending (oldest or worst match first)', + value: 'asc', + }, + ], + }, + ], + + async run($) { + const parameters = $.step.parameters; + const query = parameters.query; + const sortBy = parameters.sortBy; + const sortDirection = parameters.sortDirection; + const count = 1; + + const messages = await findMessage($, { + query, + sortBy, + sortDirection, + count, + }); + + return messages; + }, +}); diff --git a/packages/backend/src/apps/slack/actions/find-user-by-email/index.js b/packages/backend/src/apps/slack/actions/find-user-by-email/index.js new file mode 100644 index 0000000..10b99ca --- /dev/null +++ b/packages/backend/src/apps/slack/actions/find-user-by-email/index.js @@ -0,0 +1,30 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find user by email', + key: 'findUserByEmail', + description: 'Finds a user by email.', + arguments: [ + { + label: 'Email', + key: 'email', + type: 'string', + required: true, + variables: true, + }, + ], + + async run($) { + const params = { + email: $.step.parameters.email, + }; + + const { data } = await $.http.get('/users.lookupByEmail', { + params, + }); + + if (data.ok) { + $.setActionItem({ raw: data.user }); + } + }, +}); diff --git a/packages/backend/src/apps/slack/actions/index.js b/packages/backend/src/apps/slack/actions/index.js new file mode 100644 index 0000000..f10776f --- /dev/null +++ b/packages/backend/src/apps/slack/actions/index.js @@ -0,0 +1,11 @@ +import findMessage from './find-message/index.js'; +import findUserByEmail from './find-user-by-email/index.js'; +import sendMessageToChannel from './send-a-message-to-channel/index.js'; +import sendDirectMessage from './send-a-direct-message/index.js'; + +export default [ + findMessage, + findUserByEmail, + sendMessageToChannel, + sendDirectMessage, +]; diff --git a/packages/backend/src/apps/slack/actions/send-a-direct-message/index.js b/packages/backend/src/apps/slack/actions/send-a-direct-message/index.js new file mode 100644 index 0000000..0d6fa91 --- /dev/null +++ b/packages/backend/src/apps/slack/actions/send-a-direct-message/index.js @@ -0,0 +1,77 @@ +import defineAction from '../../../../helpers/define-action.js'; +import postMessage from './post-message.js'; + +export default defineAction({ + name: 'Send a direct message', + key: 'sendDirectMessage', + description: + 'Sends a direct message to a user or yourself from the Slackbot.', + arguments: [ + { + label: 'To username', + key: 'toUsername', + type: 'dropdown', + required: true, + description: 'Pick a user to send the message to.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + ], + }, + }, + { + label: 'Message text', + key: 'message', + type: 'string', + required: true, + description: 'The content of your new message.', + variables: true, + }, + { + label: 'Send as a bot?', + key: 'sendAsBot', + type: 'dropdown', + required: false, + value: false, + description: + 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', + variables: true, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listFieldsAfterSendAsBot', + }, + { + name: 'parameters.sendAsBot', + value: '{parameters.sendAsBot}', + }, + ], + }, + }, + ], + + async run($) { + const message = await postMessage($); + + return message; + }, +}); diff --git a/packages/backend/src/apps/slack/actions/send-a-direct-message/post-message.js b/packages/backend/src/apps/slack/actions/send-a-direct-message/post-message.js new file mode 100644 index 0000000..0045cfc --- /dev/null +++ b/packages/backend/src/apps/slack/actions/send-a-direct-message/post-message.js @@ -0,0 +1,46 @@ +import { URL } from 'url'; + +const postMessage = async ($) => { + const { parameters } = $.step; + const toUsername = parameters.toUsername; + const text = parameters.message; + const sendAsBot = parameters.sendAsBot; + const botName = parameters.botName; + const botIcon = parameters.botIcon; + + const data = { + channel: toUsername, + text, + }; + + if (sendAsBot) { + data.username = botName; + try { + // challenging the input to check if it is a URL! + new URL(botIcon); + data.icon_url = botIcon; + } catch { + data.icon_emoji = botIcon; + } + } + + const customConfig = { + sendAsBot, + }; + + const response = await $.http.post('/chat.postMessage', data, { + additionalProperties: customConfig, + }); + + if (response.data.ok === false) { + throw new Error(JSON.stringify(response.data)); + } + + const message = { + raw: response?.data, + }; + + $.setActionItem(message); +}; + +export default postMessage; diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.js b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.js new file mode 100644 index 0000000..5a25e86 --- /dev/null +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.js @@ -0,0 +1,76 @@ +import defineAction from '../../../../helpers/define-action.js'; +import postMessage from './post-message.js'; + +export default defineAction({ + name: 'Send a message to channel', + key: 'sendMessageToChannel', + description: 'Sends a message to a channel you specify.', + arguments: [ + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + required: true, + description: 'Pick a channel to send the message to.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listChannels', + }, + ], + }, + }, + { + label: 'Message text', + key: 'message', + type: 'string', + required: true, + description: 'The content of your new message.', + variables: true, + }, + { + label: 'Send as a bot?', + key: 'sendAsBot', + type: 'dropdown', + required: false, + value: false, + description: + 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', + variables: true, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listFieldsAfterSendAsBot', + }, + { + name: 'parameters.sendAsBot', + value: '{parameters.sendAsBot}', + }, + ], + }, + }, + ], + + async run($) { + const message = await postMessage($); + + return message; + }, +}); diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.js b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.js new file mode 100644 index 0000000..3b46679 --- /dev/null +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.js @@ -0,0 +1,46 @@ +import { URL } from 'url'; + +const postMessage = async ($) => { + const { parameters } = $.step; + const channelId = parameters.channel; + const text = parameters.message; + const sendAsBot = parameters.sendAsBot; + const botName = parameters.botName; + const botIcon = parameters.botIcon; + + const data = { + channel: channelId, + text, + }; + + if (sendAsBot) { + data.username = botName; + try { + // challenging the input to check if it is a URL! + new URL(botIcon); + data.icon_url = botIcon; + } catch { + data.icon_emoji = botIcon; + } + } + + const customConfig = { + sendAsBot, + }; + + const response = await $.http.post('/chat.postMessage', data, { + additionalProperties: customConfig, + }); + + if (response.data.ok === false) { + throw new Error(JSON.stringify(response.data)); + } + + const message = { + raw: response?.data, + }; + + $.setActionItem(message); +}; + +export default postMessage; diff --git a/packages/backend/src/apps/slack/assets/favicon.svg b/packages/backend/src/apps/slack/assets/favicon.svg new file mode 100644 index 0000000..c09453b --- /dev/null +++ b/packages/backend/src/apps/slack/assets/favicon.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/slack/auth/generate-auth-url.js b/packages/backend/src/apps/slack/auth/generate-auth-url.js new file mode 100644 index 0000000..58f225c --- /dev/null +++ b/packages/backend/src/apps/slack/auth/generate-auth-url.js @@ -0,0 +1,62 @@ +import qs from 'qs'; + +const scopes = [ + 'channels:manage', + 'channels:read', + 'channels:join', + 'chat:write', + 'chat:write.customize', + 'chat:write.public', + 'files:write', + 'im:write', + 'mpim:write', + 'team:read', + 'users.profile:read', + 'users:read', + 'workflow.steps:execute', + 'users:read.email', + 'commands', +]; +const userScopes = [ + 'channels:history', + 'channels:read', + 'channels:write', + 'chat:write', + 'emoji:read', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'groups:write', + 'im:read', + 'im:write', + 'mpim:write', + 'reactions:read', + 'reminders:write', + 'search:read', + 'stars:read', + 'team:read', + 'users.profile:read', + 'users.profile:write', + 'users:read', + 'users:read.email', +]; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = qs.stringify({ + client_id: $.auth.data.consumerKey, + redirect_uri: redirectUri, + scope: scopes.join(','), + user_scope: userScopes.join(','), + }); + + const url = `${$.app.baseUrl}/oauth/v2/authorize?${searchParams}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/slack/auth/index.js b/packages/backend/src/apps/slack/auth/index.js new file mode 100644 index 0000000..48adcf8 --- /dev/null +++ b/packages/backend/src/apps/slack/auth/index.js @@ -0,0 +1,46 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/slack/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Slack OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'API Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/slack/auth/is-still-verified.js b/packages/backend/src/apps/slack/auth/is-still-verified.js new file mode 100644 index 0000000..1b9d46d --- /dev/null +++ b/packages/backend/src/apps/slack/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/slack/auth/verify-credentials.js b/packages/backend/src/apps/slack/auth/verify-credentials.js new file mode 100644 index 0000000..c00f7d8 --- /dev/null +++ b/packages/backend/src/apps/slack/auth/verify-credentials.js @@ -0,0 +1,45 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = { + code: $.auth.data.code, + client_id: $.auth.data.consumerKey, + client_secret: $.auth.data.consumerSecret, + redirect_uri: redirectUri, + }; + const response = await $.http.post('/oauth.v2.access', null, { params }); + + if (response.data.ok === false) { + throw new Error( + `Error occured while verifying credentials: ${response.data.error}. (More info: https://api.slack.com/methods/oauth.v2.access#errors)` + ); + } + + const { + bot_user_id: botId, + authed_user: { id: userId, access_token: userAccessToken }, + access_token: botAccessToken, + team: { name: teamName }, + } = response.data; + + await $.auth.set({ + botId, + userId, + userAccessToken, + botAccessToken, + screenName: teamName, + token: $.auth.data.accessToken, + }); + + const currentUser = await getCurrentUser($); + + await $.auth.set({ + screenName: `${currentUser.real_name} @ ${teamName}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/slack/common/add-auth-header.js b/packages/backend/src/apps/slack/common/add-auth-header.js new file mode 100644 index 0000000..bd650e4 --- /dev/null +++ b/packages/backend/src/apps/slack/common/add-auth-header.js @@ -0,0 +1,21 @@ +const addAuthHeader = ($, requestConfig) => { + const authData = $.auth.data; + if ( + requestConfig.headers && + authData?.userAccessToken && + authData?.botAccessToken + ) { + if (requestConfig.additionalProperties?.sendAsBot) { + requestConfig.headers.Authorization = `Bearer ${authData.botAccessToken}`; + } else { + requestConfig.headers.Authorization = `Bearer ${authData.userAccessToken}`; + } + } + + requestConfig.headers['Content-Type'] = + requestConfig.headers['Content-Type'] || 'application/json; charset=utf-8'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/slack/common/get-current-user.js b/packages/backend/src/apps/slack/common/get-current-user.js new file mode 100644 index 0000000..7b87486 --- /dev/null +++ b/packages/backend/src/apps/slack/common/get-current-user.js @@ -0,0 +1,11 @@ +const getCurrentUser = async ($) => { + const params = { + user: $.auth.data.userId, + }; + const response = await $.http.get('/users.info', { params }); + const currentUser = response.data.user; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/slack/dynamic-data/index.js b/packages/backend/src/apps/slack/dynamic-data/index.js new file mode 100644 index 0000000..e0044a4 --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listChannels from './list-channels/index.js'; +import listUsers from './list-users/index.js'; + +export default [listChannels, listUsers]; diff --git a/packages/backend/src/apps/slack/dynamic-data/list-channels/index.js b/packages/backend/src/apps/slack/dynamic-data/list-channels/index.js new file mode 100644 index 0000000..fe0310c --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-data/list-channels/index.js @@ -0,0 +1,43 @@ +export default { + name: 'List channels', + key: 'listChannels', + + async run($) { + const channels = { + data: [], + error: null, + }; + + let nextCursor; + do { + const response = await $.http.get('/conversations.list', { + params: { + types: 'public_channel,private_channel', + cursor: nextCursor, + limit: 1000, + }, + }); + + nextCursor = response.data.response_metadata?.next_cursor; + + if (response.data.error === 'missing_scope') { + throw new Error( + `Missing "${response.data.needed}" scope while authorizing. Please, reconnect your connection!` + ); + } + + if (response.data.ok === false) { + throw new Error(JSON.stringify(response.data, null, 2)); + } + + for (const channel of response.data.channels) { + channels.data.push({ + value: channel.id, + name: channel.name, + }); + } + } while (nextCursor); + + return channels; + }, +}; diff --git a/packages/backend/src/apps/slack/dynamic-data/list-users/index.js b/packages/backend/src/apps/slack/dynamic-data/list-users/index.js new file mode 100644 index 0000000..49935bc --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-data/list-users/index.js @@ -0,0 +1,43 @@ +export default { + name: 'List users', + key: 'listUsers', + + async run($) { + const users = { + data: [], + error: null, + }; + + let nextCursor; + + do { + const response = await $.http.get('/users.list', { + params: { + cursor: nextCursor, + limit: 1000, + }, + }); + + nextCursor = response.data.response_metadata?.next_cursor; + + if (response.data.error === 'missing_scope') { + throw new Error( + `Missing "${response.data.needed}" scope while authorizing. Please, reconnect your connection!` + ); + } + + if (response.data.ok === false) { + throw new Error(JSON.stringify(response.data, null, 2)); + } + + for (const member of response.data.members) { + users.data.push({ + value: member.id, + name: member.profile.real_name_normalized, + }); + } + } while (nextCursor); + + return users; + }, +}; diff --git a/packages/backend/src/apps/slack/dynamic-fields/index.js b/packages/backend/src/apps/slack/dynamic-fields/index.js new file mode 100644 index 0000000..0509836 --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listFieldsAfterSendAsBot from './send-as-bot/index.js'; + +export default [listFieldsAfterSendAsBot]; diff --git a/packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.js b/packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.js new file mode 100644 index 0000000..a7a23a7 --- /dev/null +++ b/packages/backend/src/apps/slack/dynamic-fields/send-as-bot/index.js @@ -0,0 +1,30 @@ +export default { + name: 'List fields after send as bot', + key: 'listFieldsAfterSendAsBot', + + async run($) { + if ($.step.parameters.sendAsBot) { + return [ + { + label: 'Bot name', + key: 'botName', + type: 'string', + required: true, + value: 'Automatisch', + description: + 'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.', + variables: true, + }, + { + label: 'Bot icon', + key: 'botIcon', + type: 'string', + required: false, + description: + 'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:', + variables: true, + }, + ]; + } + }, +}; diff --git a/packages/backend/src/apps/slack/index.js b/packages/backend/src/apps/slack/index.js new file mode 100644 index 0000000..733965c --- /dev/null +++ b/packages/backend/src/apps/slack/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import actions from './actions/index.js'; +import auth from './auth/index.js'; +import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'Slack', + key: 'slack', + iconUrl: '{BASE_URL}/apps/slack/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/slack/connection', + supportsConnections: true, + baseUrl: 'https://slack.com', + apiBaseUrl: 'https://slack.com/api', + primaryColor: '#4a154b', + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, + dynamicFields, +}); diff --git a/packages/backend/src/apps/smtp/actions/index.js b/packages/backend/src/apps/smtp/actions/index.js new file mode 100644 index 0000000..e1d8a99 --- /dev/null +++ b/packages/backend/src/apps/smtp/actions/index.js @@ -0,0 +1,3 @@ +import sendEmail from './send-email/index.js'; + +export default [sendEmail]; diff --git a/packages/backend/src/apps/smtp/actions/send-email/index.js b/packages/backend/src/apps/smtp/actions/send-email/index.js new file mode 100644 index 0000000..44985de --- /dev/null +++ b/packages/backend/src/apps/smtp/actions/send-email/index.js @@ -0,0 +1,90 @@ +import defineAction from '../../../../helpers/define-action.js'; +import transporter from '../../common/transporter.js'; + +export default defineAction({ + name: 'Send an email', + key: 'sendEmail', + description: 'Sends an email', + arguments: [ + { + label: 'From name', + key: 'fromName', + type: 'string', + required: false, + description: 'Display name of the sender.', + variables: true, + }, + { + label: 'From email', + key: 'fromEmail', + type: 'string', + required: true, + description: 'Email address of the sender.', + variables: true, + }, + { + label: 'Reply to', + key: 'replyTo', + type: 'string', + required: false, + description: + 'Email address to reply to. Defaults to the from email address.', + variables: true, + }, + { + label: 'To', + key: 'to', + type: 'string', + required: true, + description: + 'Comma seperated list of email addresses to send the email to.', + variables: true, + }, + { + label: 'Cc', + key: 'cc', + type: 'string', + required: false, + description: 'Comma seperated list of email addresses.', + variables: true, + }, + { + label: 'Bcc', + key: 'bcc', + type: 'string', + required: false, + description: 'Comma seperated list of email addresses.', + variables: true, + }, + { + label: 'Subject', + key: 'subject', + type: 'string', + required: true, + description: 'Subject of the email.', + variables: true, + }, + { + label: 'Body', + key: 'body', + type: 'string', + required: true, + description: 'Body of the email.', + variables: true, + }, + ], + + async run($) { + const info = await transporter($).sendMail({ + from: `${$.step.parameters.fromName} <${$.step.parameters.fromEmail}>`, + to: $.step.parameters.to.split(','), + replyTo: $.step.parameters.replyTo, + cc: $.step.parameters.cc.split(','), + bcc: $.step.parameters.bcc.split(','), + subject: $.step.parameters.subject, + text: $.step.parameters.body, + }); + + $.setActionItem({ raw: info }); + }, +}); diff --git a/packages/backend/src/apps/smtp/assets/favicon.svg b/packages/backend/src/apps/smtp/assets/favicon.svg new file mode 100644 index 0000000..57f0fa5 --- /dev/null +++ b/packages/backend/src/apps/smtp/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/backend/src/apps/smtp/auth/index.js b/packages/backend/src/apps/smtp/auth/index.js new file mode 100644 index 0000000..090e691 --- /dev/null +++ b/packages/backend/src/apps/smtp/auth/index.js @@ -0,0 +1,91 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'host', + label: 'Host', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'The host information Automatisch will connect to.', + docUrl: 'https://automatisch.io/docs/smtp#host', + clickToCopy: false, + }, + { + key: 'username', + label: 'Email/Username', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Your SMTP login credentials.', + docUrl: 'https://automatisch.io/docs/smtp#username', + clickToCopy: false, + }, + { + key: 'password', + label: 'Password', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/smtp#password', + clickToCopy: false, + }, + { + key: 'useTls', + label: 'Use TLS?', + type: 'dropdown', + required: false, + readOnly: false, + value: false, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/smtp#use-tls', + clickToCopy: false, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + { + key: 'port', + label: 'Port', + type: 'string', + required: false, + readOnly: false, + value: '25', + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/smtp#port', + clickToCopy: false, + }, + { + key: 'fromEmail', + label: 'From Email', + type: 'string', + required: false, + readOnly: false, + value: null, + placeholder: null, + description: null, + docUrl: 'https://automatisch.io/docs/smtp#from-email', + clickToCopy: false, + }, + ], + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/smtp/auth/is-still-verified.js b/packages/backend/src/apps/smtp/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/smtp/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/smtp/auth/verify-credentials.js b/packages/backend/src/apps/smtp/auth/verify-credentials.js new file mode 100644 index 0000000..ef4218e --- /dev/null +++ b/packages/backend/src/apps/smtp/auth/verify-credentials.js @@ -0,0 +1,11 @@ +import transporter from '../common/transporter.js'; + +const verifyCredentials = async ($) => { + await transporter($).verify(); + + await $.auth.set({ + screenName: $.auth.data.username, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/smtp/common/transporter.js b/packages/backend/src/apps/smtp/common/transporter.js new file mode 100644 index 0000000..5071805 --- /dev/null +++ b/packages/backend/src/apps/smtp/common/transporter.js @@ -0,0 +1,15 @@ +import nodemailer from 'nodemailer'; + +const transporter = ($) => { + return nodemailer.createTransport({ + host: $.auth.data.host, + port: $.auth.data.port, + secure: $.auth.data.useTls, + auth: { + user: $.auth.data.username, + pass: $.auth.data.password, + }, + }); +}; + +export default transporter; diff --git a/packages/backend/src/apps/smtp/index.js b/packages/backend/src/apps/smtp/index.js new file mode 100644 index 0000000..ab7d65a --- /dev/null +++ b/packages/backend/src/apps/smtp/index.js @@ -0,0 +1,16 @@ +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'SMTP', + key: 'smtp', + iconUrl: '{BASE_URL}/apps/smtp/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/smtp/connection', + supportsConnections: true, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#2DAAE1', + auth, + actions, +}); diff --git a/packages/backend/src/apps/spotify/actions/create-playlist/index.js b/packages/backend/src/apps/spotify/actions/create-playlist/index.js new file mode 100644 index 0000000..8576694 --- /dev/null +++ b/packages/backend/src/apps/spotify/actions/create-playlist/index.js @@ -0,0 +1,55 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create playlist', + key: 'createPlaylist', + description: `Create playlist on user's account.`, + arguments: [ + { + label: 'Playlist name', + key: 'playlistName', + type: 'string', + required: true, + description: 'Playlist name', + variables: true, + }, + { + label: 'Playlist visibility', + key: 'playlistVisibility', + type: 'dropdown', + required: true, + description: 'Playlist visibility', + variables: true, + options: [ + { label: 'public', value: 'Public' }, + { label: 'private', value: 'Private' }, + ], + }, + { + label: 'Playlist description', + key: 'playlistDescription', + type: 'string', + required: false, + description: 'Playlist description', + variables: true, + }, + ], + + async run($) { + const playlistName = $.step.parameters.playlistName; + const playlistDescription = $.step.parameters.playlistDescription; + const playlistVisibility = + $.step.parameters.playlistVisibility === 'public' ? true : false; + + const response = await $.http.post( + `v1/users/${$.auth.data.userId}/playlists`, + { + name: playlistName, + public: playlistVisibility, + description: playlistDescription, + } + ); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/spotify/actions/index.js b/packages/backend/src/apps/spotify/actions/index.js new file mode 100644 index 0000000..a520f0d --- /dev/null +++ b/packages/backend/src/apps/spotify/actions/index.js @@ -0,0 +1,3 @@ +import cratePlaylist from './create-playlist/index.js'; + +export default [cratePlaylist]; diff --git a/packages/backend/src/apps/spotify/assets/favicon.svg b/packages/backend/src/apps/spotify/assets/favicon.svg new file mode 100644 index 0000000..f84a03c --- /dev/null +++ b/packages/backend/src/apps/spotify/assets/favicon.svg @@ -0,0 +1,6 @@ + + Spotify + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/spotify/auth/generate-auth-url.js b/packages/backend/src/apps/spotify/auth/generate-auth-url.js new file mode 100644 index 0000000..ed70fc0 --- /dev/null +++ b/packages/backend/src/apps/spotify/auth/generate-auth-url.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'url'; +import scopes from '../common/scopes.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const state = Math.random().toString(); + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'client_credentials', + redirect_uri: redirectUri, + response_type: 'code', + scope: scopes.join(','), + state: state, + }); + + const url = `https://accounts.spotify.com/authorize?${searchParams}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/spotify/auth/index.js b/packages/backend/src/apps/spotify/auth/index.js new file mode 100644 index 0000000..1db348c --- /dev/null +++ b/packages/backend/src/apps/spotify/auth/index.js @@ -0,0 +1,47 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/spotify/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Spotify OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client Id', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + refreshToken, + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/spotify/auth/is-still-verified.js b/packages/backend/src/apps/spotify/auth/is-still-verified.js new file mode 100644 index 0000000..1b9d46d --- /dev/null +++ b/packages/backend/src/apps/spotify/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user.id; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/spotify/auth/refresh-token.js b/packages/backend/src/apps/spotify/auth/refresh-token.js new file mode 100644 index 0000000..b3ea8c5 --- /dev/null +++ b/packages/backend/src/apps/spotify/auth/refresh-token.js @@ -0,0 +1,31 @@ +import { Buffer } from 'node:buffer'; + +const refreshToken = async ($) => { + const response = await $.http.post( + 'https://accounts.spotify.com/api/token', + null, + { + headers: { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + params: { + refresh_token: $.auth.data.refreshToken, + grant_type: 'refresh_token', + }, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + await $.auth.set({ + accessToken: response.data.access_token, + expiresIn: response.data.expires_in, + tokenType: response.data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/spotify/auth/verify-credentials.js b/packages/backend/src/apps/spotify/auth/verify-credentials.js new file mode 100644 index 0000000..9f37fa7 --- /dev/null +++ b/packages/backend/src/apps/spotify/auth/verify-credentials.js @@ -0,0 +1,52 @@ +import getCurrentUser from '../common/get-current-user.js'; +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const params = new URLSearchParams({ + code: $.auth.data.code, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }); + + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await $.http.post( + 'https://accounts.spotify.com/api/token', + params.toString(), + { headers } + ); + + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + scope: scope, + token_type: tokenType, + } = response.data; + + await $.auth.set({ + accessToken, + refreshToken, + expiresIn, + scope, + tokenType, + }); + + const user = await getCurrentUser($); + + await $.auth.set({ + userId: user.id, + screenName: user.display_name, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/spotify/common/add-auth-header.js b/packages/backend/src/apps/spotify/common/add-auth-header.js new file mode 100644 index 0000000..38e6909 --- /dev/null +++ b/packages/backend/src/apps/spotify/common/add-auth-header.js @@ -0,0 +1,13 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.additionalProperties?.skipAddingAuthHeader) + return requestConfig; + + if ($.auth.data?.accessToken) { + const authorizationHeader = `Bearer ${$.auth.data.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/spotify/common/get-current-user.js b/packages/backend/src/apps/spotify/common/get-current-user.js new file mode 100644 index 0000000..170b252 --- /dev/null +++ b/packages/backend/src/apps/spotify/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/v1/me'); + const currentUser = response.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/spotify/common/scopes.js b/packages/backend/src/apps/spotify/common/scopes.js new file mode 100644 index 0000000..66360c4 --- /dev/null +++ b/packages/backend/src/apps/spotify/common/scopes.js @@ -0,0 +1,13 @@ +const scopes = [ + 'user-follow-read', + 'playlist-read-private', + 'playlist-read-collaborative', + 'user-library-read', + 'playlist-modify-public', + 'playlist-modify-private', + 'user-library-modify', + 'user-follow-modify', + 'user-follow-read', +]; + +export default scopes; diff --git a/packages/backend/src/apps/spotify/index.js b/packages/backend/src/apps/spotify/index.js new file mode 100644 index 0000000..d312ff5 --- /dev/null +++ b/packages/backend/src/apps/spotify/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import actions from './actions/index.js'; +import auth from './auth/index.js'; + +export default defineApp({ + name: 'Spotify', + key: 'spotify', + iconUrl: '{BASE_URL}/apps/spotify/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/spotify/connection', + supportsConnections: true, + baseUrl: 'https://spotify.com', + apiBaseUrl: 'https://api.spotify.com', + primaryColor: '#000000', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/strava/actions/create-totals-and-stats-report/index.js b/packages/backend/src/apps/strava/actions/create-totals-and-stats-report/index.js new file mode 100644 index 0000000..be3fa62 --- /dev/null +++ b/packages/backend/src/apps/strava/actions/create-totals-and-stats-report/index.js @@ -0,0 +1,18 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create totals and stats report', + key: 'createTotalsAndStatsReport', + description: + 'Create a report with recent, year to date, and all time stats of your activities', + + async run($) { + const { data } = await $.http.get( + `/v3/athletes/${$.auth.data.athleteId}/stats` + ); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/strava/actions/index.js b/packages/backend/src/apps/strava/actions/index.js new file mode 100644 index 0000000..7474a61 --- /dev/null +++ b/packages/backend/src/apps/strava/actions/index.js @@ -0,0 +1,3 @@ +import createTotalsAndStatsReport from './create-totals-and-stats-report/index.js'; + +export default [createTotalsAndStatsReport]; diff --git a/packages/backend/src/apps/strava/assets/favicon.svg b/packages/backend/src/apps/strava/assets/favicon.svg new file mode 100644 index 0000000..ddd7c85 --- /dev/null +++ b/packages/backend/src/apps/strava/assets/favicon.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/strava/auth/generate-auth-url.js b/packages/backend/src/apps/strava/auth/generate-auth-url.js new file mode 100644 index 0000000..e9542fc --- /dev/null +++ b/packages/backend/src/apps/strava/auth/generate-auth-url.js @@ -0,0 +1,19 @@ +import { URLSearchParams } from 'node:url'; + +export default async function createAuthData($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + approval_prompt: 'force', + response_type: 'code', + scope: 'read_all,profile:read_all,activity:read_all,activity:write', + }); + + await $.auth.set({ + url: `${$.app.baseUrl}/oauth/authorize?${searchParams}`, + }); +} diff --git a/packages/backend/src/apps/strava/auth/index.js b/packages/backend/src/apps/strava/auth/index.js new file mode 100644 index 0000000..2488ca8 --- /dev/null +++ b/packages/backend/src/apps/strava/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/strava/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Strava OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/strava/auth/is-still-verified.js b/packages/backend/src/apps/strava/auth/is-still-verified.js new file mode 100644 index 0000000..f59ee3b --- /dev/null +++ b/packages/backend/src/apps/strava/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/strava/auth/refresh-token.js b/packages/backend/src/apps/strava/auth/refresh-token.js new file mode 100644 index 0000000..9e7f2da --- /dev/null +++ b/packages/backend/src/apps/strava/auth/refresh-token.js @@ -0,0 +1,20 @@ +const refreshToken = async ($) => { + const params = { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }; + + const { data } = await $.http.post('/v3/oauth/token', null, { params }); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + expiresAt: data.expires_at, + tokenType: data.token_type, + refreshToken: data.refresh_token, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/strava/auth/verify-credentials.js b/packages/backend/src/apps/strava/auth/verify-credentials.js new file mode 100644 index 0000000..4e61da4 --- /dev/null +++ b/packages/backend/src/apps/strava/auth/verify-credentials.js @@ -0,0 +1,19 @@ +const verifyCredentials = async ($) => { + const params = { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + }; + const { data } = await $.http.post('/v3/oauth/token', null, { params }); + + await $.auth.set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + tokenType: data.token_type, + athleteId: data.athlete.id, + screenName: `${data.athlete.firstname} ${data.athlete.lastname}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/strava/common/add-auth-header.js b/packages/backend/src/apps/strava/common/add-auth-header.js new file mode 100644 index 0000000..e8347e5 --- /dev/null +++ b/packages/backend/src/apps/strava/common/add-auth-header.js @@ -0,0 +1,11 @@ +const addAuthHeader = ($, requestConfig) => { + const { accessToken, tokenType } = $.auth.data; + + if (accessToken && tokenType) { + requestConfig.headers.Authorization = `${tokenType} ${accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/strava/common/get-current-user.js b/packages/backend/src/apps/strava/common/get-current-user.js new file mode 100644 index 0000000..db93f1f --- /dev/null +++ b/packages/backend/src/apps/strava/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/v3/athlete'); + const currentUser = response.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/strava/index.js b/packages/backend/src/apps/strava/index.js new file mode 100644 index 0000000..f8c357d --- /dev/null +++ b/packages/backend/src/apps/strava/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import actions from './actions/index.js'; +import auth from './auth/index.js'; + +export default defineApp({ + name: 'Strava', + key: 'strava', + iconUrl: '{BASE_URL}/apps/strava/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/connections/strava', + supportsConnections: true, + baseUrl: 'https://www.strava.com', + apiBaseUrl: 'https://www.strava.com/api', + primaryColor: '#fc4c01', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/stripe/assets/favicon.svg b/packages/backend/src/apps/stripe/assets/favicon.svg new file mode 100644 index 0000000..25d00aa --- /dev/null +++ b/packages/backend/src/apps/stripe/assets/favicon.svg @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/stripe/auth/index.js b/packages/backend/src/apps/stripe/auth/index.js new file mode 100644 index 0000000..e6da3ef --- /dev/null +++ b/packages/backend/src/apps/stripe/auth/index.js @@ -0,0 +1,32 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'secretKey', + label: 'Secret Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'displayName', + label: 'Account Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'The display name that identifies this stripe connection - most likely the associated account name', + clickToCopy: false, + }, + ], + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/stripe/auth/is-still-verified.js b/packages/backend/src/apps/stripe/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/stripe/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/stripe/auth/verify-credentials.js b/packages/backend/src/apps/stripe/auth/verify-credentials.js new file mode 100644 index 0000000..fab0a86 --- /dev/null +++ b/packages/backend/src/apps/stripe/auth/verify-credentials.js @@ -0,0 +1,8 @@ +const verifyCredentials = async ($) => { + await $.http.get(`/v1/events`); + await $.auth.set({ + screenName: $.auth.data?.displayName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/stripe/common/add-auth-header.js b/packages/backend/src/apps/stripe/common/add-auth-header.js new file mode 100644 index 0000000..7299246 --- /dev/null +++ b/packages/backend/src/apps/stripe/common/add-auth-header.js @@ -0,0 +1,6 @@ +const addAuthHeader = ($, requestConfig) => { + requestConfig.headers['Authorization'] = `Bearer ${$.auth.data?.secretKey}`; + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/stripe/index.js b/packages/backend/src/apps/stripe/index.js new file mode 100644 index 0000000..4a015e6 --- /dev/null +++ b/packages/backend/src/apps/stripe/index.js @@ -0,0 +1,19 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Stripe', + key: 'stripe', + iconUrl: '{BASE_URL}/apps/stripe/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/stripe/connection', + supportsConnections: true, + baseUrl: 'https://stripe.com', + apiBaseUrl: 'https://api.stripe.com', + primaryColor: '#635bff', + beforeRequest: [addAuthHeader], + auth, + triggers, + actions: [], +}); diff --git a/packages/backend/src/apps/stripe/triggers/balance-transaction/get-balance-transactions.js b/packages/backend/src/apps/stripe/triggers/balance-transaction/get-balance-transactions.js new file mode 100644 index 0000000..e28c261 --- /dev/null +++ b/packages/backend/src/apps/stripe/triggers/balance-transaction/get-balance-transactions.js @@ -0,0 +1,32 @@ +import { URLSearchParams } from 'url'; +import isEmpty from 'lodash/isEmpty.js'; +import omitBy from 'lodash/omitBy.js'; + +const getBalanceTransactions = async ($) => { + let response; + let lastId = undefined; + + do { + const params = { + starting_after: lastId, + ending_before: $.flow.lastInternalId, + }; + const queryParams = new URLSearchParams(omitBy(params, isEmpty)); + const requestPath = `/v1/balance_transactions${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = (await $.http.get(requestPath)).data; + for (const entry of response.data) { + $.pushTriggerItem({ + raw: entry, + meta: { + internalId: entry.id, + }, + }); + lastId = entry.id; + } + } while (response.has_more); +}; + +export default getBalanceTransactions; diff --git a/packages/backend/src/apps/stripe/triggers/balance-transaction/index.js b/packages/backend/src/apps/stripe/triggers/balance-transaction/index.js new file mode 100644 index 0000000..ecdf052 --- /dev/null +++ b/packages/backend/src/apps/stripe/triggers/balance-transaction/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import getBalanceTransactions from './get-balance-transactions.js'; + +export default defineTrigger({ + name: 'New balance transactions', + key: 'newBalanceTransactions', + description: + 'Triggers when a new transaction is processed (refund, payout, adjustment, ...)', + pollInterval: 15, + async run($) { + await getBalanceTransactions($); + }, +}); diff --git a/packages/backend/src/apps/stripe/triggers/index.js b/packages/backend/src/apps/stripe/triggers/index.js new file mode 100644 index 0000000..aa6b755 --- /dev/null +++ b/packages/backend/src/apps/stripe/triggers/index.js @@ -0,0 +1,4 @@ +import balanceTransaction from './balance-transaction/index.js'; +import payouts from './payouts/index.js'; + +export default [balanceTransaction, payouts]; diff --git a/packages/backend/src/apps/stripe/triggers/payouts/get-payouts.js b/packages/backend/src/apps/stripe/triggers/payouts/get-payouts.js new file mode 100644 index 0000000..fbc6f7b --- /dev/null +++ b/packages/backend/src/apps/stripe/triggers/payouts/get-payouts.js @@ -0,0 +1,32 @@ +import { URLSearchParams } from 'url'; +import isEmpty from 'lodash/isEmpty.js'; +import omitBy from 'lodash/omitBy.js'; + +const getPayouts = async ($) => { + let response; + let lastId = undefined; + + do { + const params = { + starting_after: lastId, + ending_before: $.flow.lastInternalId, + }; + const queryParams = new URLSearchParams(omitBy(params, isEmpty)); + const requestPath = `/v1/payouts${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = (await $.http.get(requestPath)).data; + for (const entry of response.data) { + $.pushTriggerItem({ + raw: entry, + meta: { + internalId: entry.id, + }, + }); + lastId = entry.id; + } + } while (response.has_more); +}; + +export default getPayouts; diff --git a/packages/backend/src/apps/stripe/triggers/payouts/index.js b/packages/backend/src/apps/stripe/triggers/payouts/index.js new file mode 100644 index 0000000..9a5a0af --- /dev/null +++ b/packages/backend/src/apps/stripe/triggers/payouts/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import getPayouts from './get-payouts.js'; + +export default defineTrigger({ + name: 'New payouts', + key: 'newPayouts', + description: + 'Triggers when a payout (Stripe <-> Bank account) has been updated', + pollInterval: 15, + async run($) { + await getPayouts($); + }, +}); diff --git a/packages/backend/src/apps/telegram-bot/actions/index.js b/packages/backend/src/apps/telegram-bot/actions/index.js new file mode 100644 index 0000000..92d67c2 --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/actions/index.js @@ -0,0 +1,3 @@ +import sendMessage from './send-message/index.js'; + +export default [sendMessage]; diff --git a/packages/backend/src/apps/telegram-bot/actions/send-message/index.js b/packages/backend/src/apps/telegram-bot/actions/send-message/index.js new file mode 100644 index 0000000..f4ec95d --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/actions/send-message/index.js @@ -0,0 +1,60 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send message', + key: 'sendMessage', + description: 'Sends a message to a chat you specify.', + arguments: [ + { + label: 'Chat ID', + key: 'chatId', + type: 'string', + required: true, + description: + 'Unique identifier for the target chat or username of the target channel (in the format @channelusername).', + variables: true, + }, + { + label: 'Message text', + key: 'text', + type: 'string', + required: true, + description: 'Text of the message to be sent, 1-4096 characters.', + variables: true, + }, + { + label: 'Disable notification?', + key: 'disableNotification', + type: 'dropdown', + required: false, + value: false, + description: + 'Sends the message silently. Users will receive a notification with no sound.', + variables: true, + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + }, + ], + + async run($) { + const payload = { + chat_id: $.step.parameters.chatId, + text: $.step.parameters.text, + disable_notification: $.step.parameters.disableNotification, + }; + + const response = await $.http.post('/sendMessage', payload); + + $.setActionItem({ + raw: response.data, + }); + }, +}); diff --git a/packages/backend/src/apps/telegram-bot/assets/favicon.svg b/packages/backend/src/apps/telegram-bot/assets/favicon.svg new file mode 100644 index 0000000..8f16fb1 --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/assets/favicon.svg @@ -0,0 +1,14 @@ + + + Telegram + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/telegram-bot/auth/index.js b/packages/backend/src/apps/telegram-bot/auth/index.js new file mode 100644 index 0000000..c8a7aaa --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/auth/index.js @@ -0,0 +1,21 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'token', + label: 'Bot token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Bot token which should be retrieved from @botfather.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/telegram-bot/auth/is-still-verified.js b/packages/backend/src/apps/telegram-bot/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/telegram-bot/auth/verify-credentials.js b/packages/backend/src/apps/telegram-bot/auth/verify-credentials.js new file mode 100644 index 0000000..594fc1a --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/auth/verify-credentials.js @@ -0,0 +1,10 @@ +const verifyCredentials = async ($) => { + const { data } = await $.http.get('/getMe'); + const { result: me } = data; + + await $.auth.set({ + screenName: me.first_name, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/telegram-bot/common/add-auth-header.js b/packages/backend/src/apps/telegram-bot/common/add-auth-header.js new file mode 100644 index 0000000..0c0228d --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/common/add-auth-header.js @@ -0,0 +1,15 @@ +import { URL } from 'node:url'; + +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.token) { + const token = $.auth.data.token; + requestConfig.baseURL = new URL( + `/bot${token}`, + requestConfig.baseURL + ).toString(); + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/telegram-bot/index.js b/packages/backend/src/apps/telegram-bot/index.js new file mode 100644 index 0000000..dedfa81 --- /dev/null +++ b/packages/backend/src/apps/telegram-bot/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'Telegram', + key: 'telegram-bot', + iconUrl: '{BASE_URL}/apps/telegram-bot/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/telegram-bot/connection', + supportsConnections: true, + baseUrl: 'https://telegram.org', + apiBaseUrl: 'https://api.telegram.org', + primaryColor: '#2AABEE', + beforeRequest: [addAuthHeader], + auth, + actions, +}); diff --git a/packages/backend/src/apps/todoist/actions/create-task/index.js b/packages/backend/src/apps/todoist/actions/create-task/index.js new file mode 100644 index 0000000..239e2ed --- /dev/null +++ b/packages/backend/src/apps/todoist/actions/create-task/index.js @@ -0,0 +1,93 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create task', + key: 'createTask', + description: 'Creates a Task in Todoist', + arguments: [ + { + label: 'Project ID', + key: 'projectId', + type: 'dropdown', + required: false, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listProjects', + }, + ], + }, + }, + { + label: 'Section ID', + key: 'sectionId', + type: 'dropdown', + required: false, + variables: true, + dependsOn: ['parameters.projectId'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSections', + }, + { + name: 'parameters.projectId', + value: '{parameters.projectId}', + }, + ], + }, + }, + { + label: 'Labels', + key: 'labels', + type: 'string', + required: false, + variables: true, + description: + 'Labels to add to task (comma separated). Examples: "work" "work,imported"', + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + description: 'Task content, may be markdown. Example: "Foo"', + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + variables: true, + description: 'Task description, may be markdown. Example: "Foo"', + }, + ], + + async run($) { + const requestPath = `/tasks`; + const { projectId, sectionId, labels, content, description } = + $.step.parameters; + + const labelsArray = labels.split(','); + + const payload = { + content, + description: description || null, + project_id: projectId || null, + labels: labelsArray || null, + section_id: sectionId || null, + }; + + const response = await $.http.post(requestPath, payload); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/todoist/actions/index.js b/packages/backend/src/apps/todoist/actions/index.js new file mode 100644 index 0000000..dc3e333 --- /dev/null +++ b/packages/backend/src/apps/todoist/actions/index.js @@ -0,0 +1,3 @@ +import createTask from './create-task/index.js'; + +export default [createTask]; diff --git a/packages/backend/src/apps/todoist/assets/favicon.svg b/packages/backend/src/apps/todoist/assets/favicon.svg new file mode 100644 index 0000000..679cdc6 --- /dev/null +++ b/packages/backend/src/apps/todoist/assets/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/todoist/auth/generate-auth-url.js b/packages/backend/src/apps/todoist/auth/generate-auth-url.js new file mode 100644 index 0000000..6c9cb2a --- /dev/null +++ b/packages/backend/src/apps/todoist/auth/generate-auth-url.js @@ -0,0 +1,15 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const scopes = ['data:read_write']; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + scope: scopes.join(','), + }); + + const url = `${$.app.baseUrl}/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/todoist/auth/index.js b/packages/backend/src/apps/todoist/auth/index.js new file mode 100644 index 0000000..3c9037a --- /dev/null +++ b/packages/backend/src/apps/todoist/auth/index.js @@ -0,0 +1,58 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/todoist/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Todoist OAuth, enter the URL above.', + docUrl: 'https://automatisch.io/docs/todoist#oauth-redirect-url', + clickToCopy: true, + }, + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Name your connection (only used for Automatisch UI).', + clickToCopy: false, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/todoist/auth/is-still-verified.js b/packages/backend/src/apps/todoist/auth/is-still-verified.js new file mode 100644 index 0000000..367cf91 --- /dev/null +++ b/packages/backend/src/apps/todoist/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/projects'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/todoist/auth/verify-credentials.js b/packages/backend/src/apps/todoist/auth/verify-credentials.js new file mode 100644 index 0000000..92d0a40 --- /dev/null +++ b/packages/backend/src/apps/todoist/auth/verify-credentials.js @@ -0,0 +1,14 @@ +const verifyCredentials = async ($) => { + const { data } = await $.http.post(`${$.app.baseUrl}/oauth/access_token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + }); + + await $.auth.set({ + tokenType: data.token_type, + accessToken: data.access_token, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/todoist/common/add-auth-header.js b/packages/backend/src/apps/todoist/common/add-auth-header.js new file mode 100644 index 0000000..2730fee --- /dev/null +++ b/packages/backend/src/apps/todoist/common/add-auth-header.js @@ -0,0 +1,11 @@ +const addAuthHeader = ($, requestConfig) => { + const authData = $.auth.data; + if (authData?.accessToken && authData?.tokenType) { + const authorizationHeader = `${authData.tokenType} ${authData.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/todoist/dynamic-data/index.js b/packages/backend/src/apps/todoist/dynamic-data/index.js new file mode 100644 index 0000000..31040d1 --- /dev/null +++ b/packages/backend/src/apps/todoist/dynamic-data/index.js @@ -0,0 +1,5 @@ +import listProjects from './list-projects/index.js'; +import listSections from './list-sections/index.js'; +import listLabels from './list-labels/index.js'; + +export default [listProjects, listSections, listLabels]; diff --git a/packages/backend/src/apps/todoist/dynamic-data/list-labels/index.js b/packages/backend/src/apps/todoist/dynamic-data/list-labels/index.js new file mode 100644 index 0000000..bee03fa --- /dev/null +++ b/packages/backend/src/apps/todoist/dynamic-data/list-labels/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List labels', + key: 'listLabels', + + async run($) { + const response = await $.http.get('/labels'); + + response.data = response.data.map((label) => { + return { + value: label.name, + name: label.name, + }; + }); + + return response; + }, +}; diff --git a/packages/backend/src/apps/todoist/dynamic-data/list-projects/index.js b/packages/backend/src/apps/todoist/dynamic-data/list-projects/index.js new file mode 100644 index 0000000..ad13173 --- /dev/null +++ b/packages/backend/src/apps/todoist/dynamic-data/list-projects/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List projects', + key: 'listProjects', + + async run($) { + const response = await $.http.get('/projects'); + + response.data = response.data.map((project) => { + return { + value: project.id, + name: project.name, + }; + }); + + return response; + }, +}; diff --git a/packages/backend/src/apps/todoist/dynamic-data/list-sections/index.js b/packages/backend/src/apps/todoist/dynamic-data/list-sections/index.js new file mode 100644 index 0000000..c4ea1d6 --- /dev/null +++ b/packages/backend/src/apps/todoist/dynamic-data/list-sections/index.js @@ -0,0 +1,21 @@ +export default { + name: 'List sections', + key: 'listSections', + + async run($) { + const params = { + project_id: $.step.parameters.projectId, + }; + + const response = await $.http.get('/sections', { params }); + + response.data = response.data.map((section) => { + return { + value: section.id, + name: section.name, + }; + }); + + return response; + }, +}; diff --git a/packages/backend/src/apps/todoist/index.js b/packages/backend/src/apps/todoist/index.js new file mode 100644 index 0000000..97008ab --- /dev/null +++ b/packages/backend/src/apps/todoist/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Todoist', + key: 'todoist', + iconUrl: '{BASE_URL}/apps/todoist/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/todoist/connection', + supportsConnections: true, + baseUrl: 'https://todoist.com', + apiBaseUrl: 'https://api.todoist.com/rest/v2', + primaryColor: '#e44332', + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/todoist/triggers/get-tasks/get-tasks.js b/packages/backend/src/apps/todoist/triggers/get-tasks/get-tasks.js new file mode 100644 index 0000000..b4b0b89 --- /dev/null +++ b/packages/backend/src/apps/todoist/triggers/get-tasks/get-tasks.js @@ -0,0 +1,26 @@ +const getActiveTasks = async ($) => { + const params = { + project_id: $.step.parameters.projectId?.trim(), + section_id: $.step.parameters.sectionId?.trim(), + label: $.step.parameters.label?.trim(), + filter: $.step.parameters.filter?.trim(), + }; + + const response = await $.http.get('/tasks', { params }); + + // todoist api doesn't offer sorting, so we inverse sort on id here + response.data.sort((a, b) => { + return b.id - a.id; + }); + + for (const task of response.data) { + $.pushTriggerItem({ + raw: task, + meta: { + internalId: task.id, + }, + }); + } +}; + +export default getActiveTasks; diff --git a/packages/backend/src/apps/todoist/triggers/get-tasks/index.js b/packages/backend/src/apps/todoist/triggers/get-tasks/index.js new file mode 100644 index 0000000..74dedd1 --- /dev/null +++ b/packages/backend/src/apps/todoist/triggers/get-tasks/index.js @@ -0,0 +1,80 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import getActiveTasks from './get-tasks.js'; + +export default defineTrigger({ + name: 'Get active tasks', + key: 'getActiveTasks', + pollInterval: 15, + description: 'Triggers when new Task(s) are found', + arguments: [ + { + label: 'Project ID', + key: 'projectId', + type: 'dropdown', + required: false, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listProjects', + }, + ], + }, + }, + { + label: 'Section ID', + key: 'sectionId', + type: 'dropdown', + required: false, + variables: false, + dependsOn: ['parameters.projectId'], + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSections', + }, + { + name: 'parameters.projectId', + value: '{parameters.projectId}', + }, + ], + }, + }, + { + label: 'Label', + key: 'label', + type: 'dropdown', + required: false, + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLabels', + }, + ], + }, + }, + { + label: 'Filter', + key: 'filter', + type: 'string', + required: false, + variables: false, + description: + 'Limit queried tasks to this filter. Example: "Meeting & today"', + }, + ], + + async run($) { + await getActiveTasks($); + }, +}); diff --git a/packages/backend/src/apps/todoist/triggers/index.js b/packages/backend/src/apps/todoist/triggers/index.js new file mode 100644 index 0000000..deac848 --- /dev/null +++ b/packages/backend/src/apps/todoist/triggers/index.js @@ -0,0 +1,3 @@ +import getTasks from './get-tasks/index.js'; + +export default [getTasks]; diff --git a/packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js b/packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js new file mode 100644 index 0000000..3019569 --- /dev/null +++ b/packages/backend/src/apps/together-ai/actions/create-chat-completion/index.js @@ -0,0 +1,169 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create chat completion', + key: 'createChatCompletion', + description: 'Queries a chat model.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Messages', + key: 'messages', + type: 'dynamic', + required: true, + description: 'A list of messages comprising the conversation so far.', + value: [{ role: 'system', body: '' }], + fields: [ + { + label: 'Role', + key: 'role', + type: 'dropdown', + required: true, + description: + 'The role of the messages author. Choice between: system, user, or assistant.', + options: [ + { + label: 'System', + value: 'system', + }, + { + label: 'Assistant', + value: 'assistant', + }, + { + label: 'User', + value: 'user', + }, + ], + }, + { + label: 'Content', + key: 'content', + type: 'string', + required: true, + variables: true, + description: + 'The content of the message, which can either be a simple string or a structured format.', + }, + ], + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'A decimal number from 0-1 that determines the degree of randomness in the response. A temperature less than 1 favors more correctness and is appropriate for question answering or summarization. A value closer to 1 introduces more randomness in the output.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: 'The maximum number of tokens to generate.', + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: + 'A list of string sequences that will truncate (stop) inference text output. For example, "" will stop generation as soon as the model generates the given token.', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: `A percentage (also called the nucleus parameter) that's used to dynamically adjust the number of choices for each predicted token based on the cumulative probabilities. It specifies a probability threshold below which all less likely tokens are filtered out. This technique helps maintain diversity and generate more fluent and natural-sounding text.`, + }, + { + label: 'Top K', + key: 'topK', + type: 'string', + required: false, + variables: true, + description: `An integer that's used to limit the number of choices for the next predicted word or token. It specifies the maximum number of tokens to consider at each step, based on their probability of occurrence. This technique helps to speed up the generation process and can improve the quality of the generated text by focusing on the most likely options.`, + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value decreases the likelihood of repeating tokens that have already been mentioned.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value increases the likelihood of a model talking about new topics.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const messages = $.step.parameters.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + const payload = { + model: $.step.parameters.model, + messages, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: nonEmptyStopSequences, + top_p: castFloatOrUndefined($.step.parameters.topP), + top_k: castFloatOrUndefined($.step.parameters.topK), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + }; + + const { data } = await $.http.post('/v1/chat/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/together-ai/actions/create-completion/index.js b/packages/backend/src/apps/together-ai/actions/create-completion/index.js new file mode 100644 index 0000000..702e5a0 --- /dev/null +++ b/packages/backend/src/apps/together-ai/actions/create-completion/index.js @@ -0,0 +1,131 @@ +import defineAction from '../../../../helpers/define-action.js'; + +const castFloatOrUndefined = (value) => { + return value === '' ? undefined : parseFloat(value); +}; + +export default defineAction({ + name: 'Create completion', + key: 'createCompletion', + description: 'Queries a language, code, or image model.', + arguments: [ + { + label: 'Model', + key: 'model', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listModels', + }, + ], + }, + }, + { + label: 'Prompt', + key: 'prompt', + type: 'string', + required: true, + variables: true, + description: 'A string providing context for the model to complete.', + }, + { + label: 'Temperature', + key: 'temperature', + type: 'string', + required: false, + variables: true, + description: + 'A decimal number from 0-1 that determines the degree of randomness in the response. A temperature less than 1 favors more correctness and is appropriate for question answering or summarization. A value closer to 1 introduces more randomness in the output.', + }, + { + label: 'Maximum tokens', + key: 'maxTokens', + type: 'string', + required: false, + variables: true, + description: 'The maximum number of tokens to generate.', + }, + { + label: 'Stop sequences', + key: 'stopSequences', + type: 'dynamic', + required: false, + variables: true, + description: + 'A list of string sequences that will truncate (stop) inference text output. For example, "" will stop generation as soon as the model generates the given token.', + fields: [ + { + label: 'Stop sequence', + key: 'stopSequence', + type: 'string', + required: false, + variables: true, + }, + ], + }, + { + label: 'Top P', + key: 'topP', + type: 'string', + required: false, + variables: true, + description: `A percentage (also called the nucleus parameter) that's used to dynamically adjust the number of choices for each predicted token based on the cumulative probabilities. It specifies a probability threshold below which all less likely tokens are filtered out. This technique helps maintain diversity and generate more fluent and natural-sounding text.`, + }, + { + label: 'Top K', + key: 'topK', + type: 'string', + required: false, + variables: true, + description: `An integer that's used to limit the number of choices for the next predicted word or token. It specifies the maximum number of tokens to consider at each step, based on their probability of occurrence. This technique helps to speed up the generation process and can improve the quality of the generated text by focusing on the most likely options.`, + }, + { + label: 'Frequency Penalty', + key: 'frequencyPenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value decreases the likelihood of repeating tokens that have already been mentioned.`, + }, + { + label: 'Presence Penalty', + key: 'presencePenalty', + type: 'string', + required: false, + variables: true, + description: `A number between -2.0 and 2.0 where a positive value increases the likelihood of a model talking about new topics.`, + }, + ], + + async run($) { + const nonEmptyStopSequences = $.step.parameters.stopSequences + .filter(({ stopSequence }) => stopSequence) + .map(({ stopSequence }) => stopSequence); + + const payload = { + model: $.step.parameters.model, + prompt: $.step.parameters.prompt, + temperature: castFloatOrUndefined($.step.parameters.temperature), + max_tokens: castFloatOrUndefined($.step.parameters.maxTokens), + stop: nonEmptyStopSequences, + top_p: castFloatOrUndefined($.step.parameters.topP), + top_k: castFloatOrUndefined($.step.parameters.topK), + presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty), + frequency_penalty: castFloatOrUndefined( + $.step.parameters.frequencyPenalty + ), + }; + + const { data } = await $.http.post('/v1/completions', payload); + + $.setActionItem({ + raw: data, + }); + }, +}); diff --git a/packages/backend/src/apps/together-ai/actions/index.js b/packages/backend/src/apps/together-ai/actions/index.js new file mode 100644 index 0000000..a8c2e25 --- /dev/null +++ b/packages/backend/src/apps/together-ai/actions/index.js @@ -0,0 +1,4 @@ +import createCompletion from './create-completion/index.js'; +import createChatCompletion from './create-chat-completion/index.js'; + +export default [createChatCompletion, createCompletion]; diff --git a/packages/backend/src/apps/together-ai/assets/favicon.svg b/packages/backend/src/apps/together-ai/assets/favicon.svg new file mode 100644 index 0000000..620ac88 --- /dev/null +++ b/packages/backend/src/apps/together-ai/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/together-ai/auth/index.js b/packages/backend/src/apps/together-ai/auth/index.js new file mode 100644 index 0000000..4765e6e --- /dev/null +++ b/packages/backend/src/apps/together-ai/auth/index.js @@ -0,0 +1,34 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Together AI API key of your account.', + docUrl: 'https://automatisch.io/docs/together-ai#api-key', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/together-ai/auth/is-still-verified.js b/packages/backend/src/apps/together-ai/auth/is-still-verified.js new file mode 100644 index 0000000..3e6c909 --- /dev/null +++ b/packages/backend/src/apps/together-ai/auth/is-still-verified.js @@ -0,0 +1,6 @@ +const isStillVerified = async ($) => { + await $.http.get('/v1/models'); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/together-ai/auth/verify-credentials.js b/packages/backend/src/apps/together-ai/auth/verify-credentials.js new file mode 100644 index 0000000..7f43f88 --- /dev/null +++ b/packages/backend/src/apps/together-ai/auth/verify-credentials.js @@ -0,0 +1,5 @@ +const verifyCredentials = async ($) => { + await $.http.get('/v1/models'); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/together-ai/common/add-auth-header.js b/packages/backend/src/apps/together-ai/common/add-auth-header.js new file mode 100644 index 0000000..f9f5acb --- /dev/null +++ b/packages/backend/src/apps/together-ai/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers.Authorization = `Bearer ${$.auth.data.apiKey}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/together-ai/dynamic-data/index.js b/packages/backend/src/apps/together-ai/dynamic-data/index.js new file mode 100644 index 0000000..6db4804 --- /dev/null +++ b/packages/backend/src/apps/together-ai/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listModels from './list-models/index.js'; + +export default [listModels]; diff --git a/packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js b/packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js new file mode 100644 index 0000000..2853296 --- /dev/null +++ b/packages/backend/src/apps/together-ai/dynamic-data/list-models/index.js @@ -0,0 +1,17 @@ +export default { + name: 'List models', + key: 'listModels', + + async run($) { + const { data } = await $.http.get('/v1/models'); + + const models = data.map((model) => { + return { + value: model.id, + name: model.display_name, + }; + }); + + return { data: models }; + }, +}; diff --git a/packages/backend/src/apps/together-ai/index.js b/packages/backend/src/apps/together-ai/index.js new file mode 100644 index 0000000..efddacc --- /dev/null +++ b/packages/backend/src/apps/together-ai/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Together AI', + key: 'together-ai', + baseUrl: 'https://together.ai', + apiBaseUrl: 'https://api.together.xyz', + iconUrl: '{BASE_URL}/apps/together-ai/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/together-ai/connection', + primaryColor: '#000000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/trello/actions/create-card/index.js b/packages/backend/src/apps/trello/actions/create-card/index.js new file mode 100644 index 0000000..a407bd7 --- /dev/null +++ b/packages/backend/src/apps/trello/actions/create-card/index.js @@ -0,0 +1,186 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create card', + key: 'createCard', + description: 'Creates a new card within a specified board and list.', + arguments: [ + { + label: 'Board', + key: 'boardId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBoards', + }, + ], + }, + }, + { + label: 'List', + key: 'listId', + type: 'dropdown', + required: true, + dependsOn: ['parameters.boardId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBoardLists', + }, + { + name: 'parameters.boardId', + value: '{parameters.boardId}', + }, + ], + }, + }, + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + variables: true, + description: '', + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + variables: true, + description: '', + }, + + { + label: 'Label', + key: 'label', + type: 'dropdown', + required: false, + dependsOn: ['parameters.boardId'], + description: 'Select a color tag to attach to the card.', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBoardLabels', + }, + { + name: 'parameters.boardId', + value: '{parameters.boardId}', + }, + ], + }, + }, + { + label: 'Card Position', + key: 'cardPosition', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { + label: 'top', + value: 'top', + }, + { + label: 'bottom', + value: 'bottom', + }, + ], + }, + { + label: 'Members', + key: 'memberIds', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Member', + key: 'memberId', + type: 'dropdown', + required: false, + dependsOn: ['parameters.boardId'], + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listMembers', + }, + { + name: 'parameters.boardId', + value: '{parameters.boardId}', + }, + ], + }, + }, + ], + }, + { + label: 'Due Date', + key: 'dueDate', + type: 'string', + required: false, + variables: true, + description: 'Format: mm-dd-yyyy HH:mm:ss or yyyy-MM-dd HH:mm:ss.', + }, + { + label: 'URL Attachment', + key: 'urlSource', + type: 'string', + required: false, + variables: true, + description: 'A URL to attach to the card.', + }, + ], + + async run($) { + const { + listId, + name, + description, + cardPosition, + dueDate, + label, + urlSource, + } = $.step.parameters; + + const memberIds = $.step.parameters.memberIds; + const idMembers = memberIds.map((memberId) => memberId.memberId); + + const fields = { + name, + desc: description, + idList: listId, + pos: cardPosition, + due: dueDate, + idMembers: idMembers.join(','), + idLabels: label, + urlSource, + }; + + const response = await $.http.post('/1/cards', fields); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/trello/actions/index.js b/packages/backend/src/apps/trello/actions/index.js new file mode 100644 index 0000000..a44a1bf --- /dev/null +++ b/packages/backend/src/apps/trello/actions/index.js @@ -0,0 +1,3 @@ +import createCard from './create-card/index.js'; + +export default [createCard]; diff --git a/packages/backend/src/apps/trello/assets/favicon.svg b/packages/backend/src/apps/trello/assets/favicon.svg new file mode 100644 index 0000000..7c63adb --- /dev/null +++ b/packages/backend/src/apps/trello/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/trello/auth/generate-auth-url.js b/packages/backend/src/apps/trello/auth/generate-auth-url.js new file mode 100644 index 0000000..80bd2ea --- /dev/null +++ b/packages/backend/src/apps/trello/auth/generate-auth-url.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + return_url: redirectUri, + scope: authScope.join(','), + expiration: 'never', + key: $.auth.data.apiKey, + response_type: 'token', + }); + + const url = `https://trello.com/1/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/trello/auth/index.js b/packages/backend/src/apps/trello/auth/index.js new file mode 100644 index 0000000..cf7b881 --- /dev/null +++ b/packages/backend/src/apps/trello/auth/index.js @@ -0,0 +1,34 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/trello/connections/add', + placeholder: null, + description: '', + clickToCopy: true, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API Key for your Trello account', + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/trello/auth/is-still-verified.js b/packages/backend/src/apps/trello/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/trello/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/trello/auth/verify-credentials.js b/packages/backend/src/apps/trello/auth/verify-credentials.js new file mode 100644 index 0000000..ebf87d9 --- /dev/null +++ b/packages/backend/src/apps/trello/auth/verify-credentials.js @@ -0,0 +1,14 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const currentUser = await getCurrentUser($); + const screenName = [currentUser.username, currentUser.email] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/trello/common/add-auth-header.js b/packages/backend/src/apps/trello/common/add-auth-header.js new file mode 100644 index 0000000..c82490b --- /dev/null +++ b/packages/backend/src/apps/trello/common/add-auth-header.js @@ -0,0 +1,11 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.token) { + requestConfig.headers.Authorization = `OAuth oauth_consumer_key="${$.auth.data.apiKey}", oauth_token="${$.auth.data.token}"`; + } + + requestConfig.headers.Accept = 'application/json'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/trello/common/auth-scope.js b/packages/backend/src/apps/trello/common/auth-scope.js new file mode 100644 index 0000000..805e5b7 --- /dev/null +++ b/packages/backend/src/apps/trello/common/auth-scope.js @@ -0,0 +1,3 @@ +const authScope = ['read', 'write', 'account']; + +export default authScope; diff --git a/packages/backend/src/apps/trello/common/get-current-user.js b/packages/backend/src/apps/trello/common/get-current-user.js new file mode 100644 index 0000000..a2657cb --- /dev/null +++ b/packages/backend/src/apps/trello/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/1/members/me/'); + const currentUser = response.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/trello/dynamic-data/index.js b/packages/backend/src/apps/trello/dynamic-data/index.js new file mode 100644 index 0000000..2cc30ab --- /dev/null +++ b/packages/backend/src/apps/trello/dynamic-data/index.js @@ -0,0 +1,6 @@ +import listBoardLabels from './list-board-labels/index.js'; +import listBoardLists from './list-board-lists/index.js'; +import listBoards from './list-boards/index.js'; +import listMembers from './listMembers/index.js'; + +export default [listBoardLabels, listBoardLists, listBoards, listMembers]; diff --git a/packages/backend/src/apps/trello/dynamic-data/list-board-labels/index.js b/packages/backend/src/apps/trello/dynamic-data/list-board-labels/index.js new file mode 100644 index 0000000..981d62d --- /dev/null +++ b/packages/backend/src/apps/trello/dynamic-data/list-board-labels/index.js @@ -0,0 +1,35 @@ +export default { + name: 'List board labels', + key: 'listBoardLabels', + + async run($) { + const boardLabels = { + data: [], + }; + + const boardId = $.step.parameters.boardId; + + if (!boardId) { + return boardLabels; + } + + const params = { + fields: 'color', + }; + + const { data } = await $.http.get(`/1/boards/${boardId}/labels`, { + params, + }); + + if (data?.length) { + for (const boardLabel of data) { + boardLabels.data.push({ + value: boardLabel.id, + name: boardLabel.color, + }); + } + } + + return boardLabels; + }, +}; diff --git a/packages/backend/src/apps/trello/dynamic-data/list-board-lists/index.js b/packages/backend/src/apps/trello/dynamic-data/list-board-lists/index.js new file mode 100644 index 0000000..ea46115 --- /dev/null +++ b/packages/backend/src/apps/trello/dynamic-data/list-board-lists/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List board lists', + key: 'listBoardLists', + + async run($) { + const boards = { + data: [], + }; + + const boardId = $.step.parameters.boardId; + + if (!boardId) { + return boards; + } + + const { data } = await $.http.get(`/1/boards/${boardId}/lists`); + + if (data?.length) { + for (const list of data) { + boards.data.push({ + value: list.id, + name: list.name, + }); + } + } + + return boards; + }, +}; diff --git a/packages/backend/src/apps/trello/dynamic-data/list-boards/index.js b/packages/backend/src/apps/trello/dynamic-data/list-boards/index.js new file mode 100644 index 0000000..2cf198a --- /dev/null +++ b/packages/backend/src/apps/trello/dynamic-data/list-boards/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List boards', + key: 'listBoards', + + async run($) { + const boards = { + data: [], + }; + + const { data } = await $.http.get(`/1/members/me/boards`); + + if (data?.length) { + for (const board of data) { + boards.data.push({ + value: board.id, + name: board.name, + }); + } + } + + return boards; + }, +}; diff --git a/packages/backend/src/apps/trello/dynamic-data/listMembers/index.js b/packages/backend/src/apps/trello/dynamic-data/listMembers/index.js new file mode 100644 index 0000000..4f00702 --- /dev/null +++ b/packages/backend/src/apps/trello/dynamic-data/listMembers/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List members', + key: 'listMembers', + + async run($) { + const members = { + data: [], + }; + + const boardId = $.step.parameters.boardId; + + if (!boardId) { + return members; + } + + const { data } = await $.http.get(`/1/boards/${boardId}/members`); + + if (data?.length) { + for (const member of data) { + members.data.push({ + value: member.id, + name: member.fullName, + }); + } + } + + return members; + }, +}; diff --git a/packages/backend/src/apps/trello/index.js b/packages/backend/src/apps/trello/index.js new file mode 100644 index 0000000..a793f45 --- /dev/null +++ b/packages/backend/src/apps/trello/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Trello', + key: 'trello', + baseUrl: 'https://trello.com/', + apiBaseUrl: 'https://api.trello.com', + iconUrl: '{BASE_URL}/apps/trello/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/trello/connection', + supportsConnections: true, + primaryColor: '#0079bf', + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/twilio/actions/index.js b/packages/backend/src/apps/twilio/actions/index.js new file mode 100644 index 0000000..18a261f --- /dev/null +++ b/packages/backend/src/apps/twilio/actions/index.js @@ -0,0 +1,3 @@ +import sendSms from './send-sms/index.js'; + +export default [sendSms]; diff --git a/packages/backend/src/apps/twilio/actions/send-sms/index.js b/packages/backend/src/apps/twilio/actions/send-sms/index.js new file mode 100644 index 0000000..0e0c394 --- /dev/null +++ b/packages/backend/src/apps/twilio/actions/send-sms/index.js @@ -0,0 +1,64 @@ +import { URLSearchParams } from 'node:url'; +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Send an SMS', + key: 'sendSms', + description: 'Sends an SMS', + arguments: [ + { + label: 'From Number', + key: 'fromNumber', + type: 'dropdown', + required: true, + description: + 'The number to send the SMS from. Include country code. Example: 15551234567', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listIncomingPhoneNumbers', + }, + ], + }, + }, + { + label: 'To Number', + key: 'toNumber', + type: 'string', + required: true, + description: + 'The number to send the SMS to. Include country code. Example: 15551234567', + variables: true, + }, + { + label: 'Message', + key: 'message', + type: 'string', + required: true, + description: 'The message to send.', + variables: true, + }, + ], + + async run($) { + const requestPath = `/2010-04-01/Accounts/${$.auth.data.accountSid}/Messages.json`; + const messageBody = $.step.parameters.message; + + const fromNumber = $.step.parameters.fromNumber.trim(); + const toNumber = $.step.parameters.toNumber.trim(); + + const payload = new URLSearchParams({ + Body: messageBody, + From: fromNumber, + To: toNumber, + }).toString(); + + const response = await $.http.post(requestPath, payload); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/twilio/assets/favicon.svg b/packages/backend/src/apps/twilio/assets/favicon.svg new file mode 100644 index 0000000..7c20e19 --- /dev/null +++ b/packages/backend/src/apps/twilio/assets/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/backend/src/apps/twilio/auth/index.js b/packages/backend/src/apps/twilio/auth/index.js new file mode 100644 index 0000000..d71192d --- /dev/null +++ b/packages/backend/src/apps/twilio/auth/index.js @@ -0,0 +1,33 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'accountSid', + label: 'Account SID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Log into your Twilio account and find "API Credentials" on this page https://www.twilio.com/user/account/settings', + clickToCopy: false, + }, + { + key: 'authToken', + label: 'Auth Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Found directly below your Account SID.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/twilio/auth/is-still-verified.js b/packages/backend/src/apps/twilio/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/twilio/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/twilio/auth/verify-credentials.js b/packages/backend/src/apps/twilio/auth/verify-credentials.js new file mode 100644 index 0000000..89f3692 --- /dev/null +++ b/packages/backend/src/apps/twilio/auth/verify-credentials.js @@ -0,0 +1,9 @@ +const verifyCredentials = async ($) => { + await $.http.get('/2010-04-01/Accounts.json?PageSize=1'); + + await $.auth.set({ + screenName: $.auth.data.accountSid, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/twilio/common/add-auth-header.js b/packages/backend/src/apps/twilio/common/add-auth-header.js new file mode 100644 index 0000000..097edf0 --- /dev/null +++ b/packages/backend/src/apps/twilio/common/add-auth-header.js @@ -0,0 +1,18 @@ +const addAuthHeader = ($, requestConfig) => { + if ( + requestConfig.headers && + $.auth.data?.accountSid && + $.auth.data?.authToken + ) { + requestConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + requestConfig.auth = { + username: $.auth.data.accountSid, + password: $.auth.data.authToken, + }; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/twilio/common/get-incoming-phone-number.js b/packages/backend/src/apps/twilio/common/get-incoming-phone-number.js new file mode 100644 index 0000000..cb2cdff --- /dev/null +++ b/packages/backend/src/apps/twilio/common/get-incoming-phone-number.js @@ -0,0 +1,7 @@ +export default async function getIncomingPhoneNumber($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + const path = `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`; + const response = await $.http.get(path); + + return response.data; +} diff --git a/packages/backend/src/apps/twilio/dynamic-data/index.js b/packages/backend/src/apps/twilio/dynamic-data/index.js new file mode 100644 index 0000000..758d4ab --- /dev/null +++ b/packages/backend/src/apps/twilio/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listIncomingPhoneNumbers from './list-incoming-phone-numbers/index.js'; + +export default [listIncomingPhoneNumbers]; diff --git a/packages/backend/src/apps/twilio/dynamic-data/list-incoming-phone-numbers/index.js b/packages/backend/src/apps/twilio/dynamic-data/list-incoming-phone-numbers/index.js new file mode 100644 index 0000000..f71806b --- /dev/null +++ b/packages/backend/src/apps/twilio/dynamic-data/list-incoming-phone-numbers/index.js @@ -0,0 +1,35 @@ +export default { + name: 'List incoming phone numbers', + key: 'listIncomingPhoneNumbers', + + async run($) { + const valueType = $.step.parameters.valueType; + const isSid = valueType === 'sid'; + + const aggregatedResponse = { data: [] }; + let pathname = `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers.json`; + + do { + const response = await $.http.get(pathname); + + for (const incomingPhoneNumber of response.data.incoming_phone_numbers) { + if (incomingPhoneNumber.capabilities.sms === false) { + continue; + } + + const friendlyName = incomingPhoneNumber.friendly_name; + const phoneNumber = incomingPhoneNumber.phone_number; + const name = [friendlyName, phoneNumber].filter(Boolean).join(' - '); + + aggregatedResponse.data.push({ + value: isSid ? incomingPhoneNumber.sid : phoneNumber, + name, + }); + } + + pathname = response.data.next_page_uri; + } while (pathname); + + return aggregatedResponse; + }, +}; diff --git a/packages/backend/src/apps/twilio/index.js b/packages/backend/src/apps/twilio/index.js new file mode 100644 index 0000000..c0d23c4 --- /dev/null +++ b/packages/backend/src/apps/twilio/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Twilio', + key: 'twilio', + iconUrl: '{BASE_URL}/apps/twilio/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/twilio/connection', + supportsConnections: true, + baseUrl: 'https://twilio.com', + apiBaseUrl: 'https://api.twilio.com', + primaryColor: '#e1000f', + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/twilio/triggers/index.js b/packages/backend/src/apps/twilio/triggers/index.js new file mode 100644 index 0000000..c7219e5 --- /dev/null +++ b/packages/backend/src/apps/twilio/triggers/index.js @@ -0,0 +1,3 @@ +import receiveSms from './receive-sms/index.js'; + +export default [receiveSms]; diff --git a/packages/backend/src/apps/twilio/triggers/receive-sms/fetch-messages.js b/packages/backend/src/apps/twilio/triggers/receive-sms/fetch-messages.js new file mode 100644 index 0000000..7445ced --- /dev/null +++ b/packages/backend/src/apps/twilio/triggers/receive-sms/fetch-messages.js @@ -0,0 +1,39 @@ +import getIncomingPhoneNumber from '../../common/get-incoming-phone-number.js'; + +const fetchMessages = async ($) => { + const incomingPhoneNumber = await getIncomingPhoneNumber($); + + let response; + let requestPath = `/2010-04-01/Accounts/${$.auth.data.accountSid}/Messages.json?To=${incomingPhoneNumber.phone_number}`; + + do { + response = await $.http.get(requestPath); + + response.data.messages.forEach((message) => { + const computedMessage = { + To: message.to, + Body: message.body, + From: message.from, + SmsSid: message.sid, + NumMedia: message.num_media, + SmsStatus: message.status, + AccountSid: message.account_sid, + ApiVersion: message.api_version, + NumSegments: message.num_segments, + }; + + const dataItem = { + raw: computedMessage, + meta: { + internalId: message.date_sent, + }, + }; + + $.pushTriggerItem(dataItem); + }); + + requestPath = response.data.next_page_uri; + } while (requestPath); +}; + +export default fetchMessages; diff --git a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js new file mode 100644 index 0000000..eac75b1 --- /dev/null +++ b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js @@ -0,0 +1,87 @@ +import { URLSearchParams } from 'node:url'; +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; +import fetchMessages from './fetch-messages.js'; + +export default defineTrigger({ + name: 'Receive SMS', + key: 'receiveSms', + type: 'webhook', + description: 'Triggers when a new SMS is received.', + arguments: [ + { + label: 'To Number', + key: 'phoneNumberSid', + type: 'dropdown', + required: true, + description: + 'The number to receive the SMS on. It should be a Twilio number.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listIncomingPhoneNumbers', + }, + { + name: 'parameters.valueType', + value: 'sid', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + await fetchMessages($); + + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, + + async registerHook($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + const payload = new URLSearchParams({ + SmsUrl: $.webhookUrl, + }).toString(); + + await $.http.post( + `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`, + payload + ); + }, + + async unregisterHook($) { + const phoneNumberSid = $.step.parameters.phoneNumberSid; + const payload = new URLSearchParams({ + SmsUrl: '', + }).toString(); + + await $.http.post( + `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`, + payload + ); + }, +}); diff --git a/packages/backend/src/apps/twitter/actions/create-tweet/index.js b/packages/backend/src/apps/twitter/actions/create-tweet/index.js new file mode 100644 index 0000000..f3f6d14 --- /dev/null +++ b/packages/backend/src/apps/twitter/actions/create-tweet/index.js @@ -0,0 +1,26 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Create tweet', + key: 'createTweet', + description: 'Create a tweet.', + arguments: [ + { + label: 'Tweet body', + key: 'tweet', + type: 'string', + required: true, + description: 'The content of your new tweet.', + variables: true, + }, + ], + + async run($) { + const text = $.step.parameters.tweet; + const response = await $.http.post('/2/tweets', { + text, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/twitter/actions/index.js b/packages/backend/src/apps/twitter/actions/index.js new file mode 100644 index 0000000..b090ea0 --- /dev/null +++ b/packages/backend/src/apps/twitter/actions/index.js @@ -0,0 +1,4 @@ +import createTweet from './create-tweet/index.js'; +import searchUser from './search-user/index.js'; + +export default [createTweet, searchUser]; diff --git a/packages/backend/src/apps/twitter/actions/search-user/index.js b/packages/backend/src/apps/twitter/actions/search-user/index.js new file mode 100644 index 0000000..a535e0e --- /dev/null +++ b/packages/backend/src/apps/twitter/actions/search-user/index.js @@ -0,0 +1,35 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Search user', + key: 'searchUser', + description: 'Search a user on Twitter', + arguments: [ + { + label: 'Username', + key: 'username', + type: 'string', + required: true, + description: 'The username of the Twitter user you want to search for', + variables: true, + }, + ], + + async run($) { + const { data } = await $.http.get( + `/2/users/by/username/${$.step.parameters.username}`, + { + params: { + expansions: 'pinned_tweet_id', + 'tweet.fields': + 'attachments,author_id,context_annotations,conversation_id,created_at,edit_controls,entities,geo,id,in_reply_to_user_id,lang,non_public_metrics,public_metrics,organic_metrics,promoted_metrics,possibly_sensitive,referenced_tweets,reply_settings,source,text,withheld', + 'user.fields': + 'created_at,description,entities,id,location,name,pinned_tweet_id,profile_image_url,protected,public_metrics,url,username,verified,verified_type,withheld', + }, + } + ); + $.setActionItem({ + raw: data.data, + }); + }, +}); diff --git a/packages/backend/src/apps/twitter/assets/favicon.svg b/packages/backend/src/apps/twitter/assets/favicon.svg new file mode 100644 index 0000000..576611f --- /dev/null +++ b/packages/backend/src/apps/twitter/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/twitter/auth/generate-auth-url.js b/packages/backend/src/apps/twitter/auth/generate-auth-url.js new file mode 100644 index 0000000..f1e0db8 --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/generate-auth-url.js @@ -0,0 +1,20 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + const requestPath = '/oauth/request_token'; + const data = { oauth_callback: callbackUrl }; + + const response = await $.http.post(requestPath, data); + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + url: `${$.app.baseUrl}/oauth/authorize?oauth_token=${responseData.oauth_token}`, + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + }); +} diff --git a/packages/backend/src/apps/twitter/auth/index.js b/packages/backend/src/apps/twitter/auth/index.js new file mode 100644 index 0000000..78135f8 --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/index.js @@ -0,0 +1,46 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/twitter/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Twitter OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'API Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/twitter/auth/is-still-verified.js b/packages/backend/src/apps/twitter/auth/is-still-verified.js new file mode 100644 index 0000000..f59ee3b --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const user = await getCurrentUser($); + return !!user; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/twitter/auth/verify-credentials.js b/packages/backend/src/apps/twitter/auth/verify-credentials.js new file mode 100644 index 0000000..2620883 --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/verify-credentials.js @@ -0,0 +1,19 @@ +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + const response = await $.http.post( + `/oauth/access_token?oauth_verifier=${$.auth.data.oauth_verifier}&oauth_token=${$.auth.data.accessToken}`, + null + ); + + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + userId: responseData.user_id, + screenName: responseData.screen_name, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/twitter/common/add-auth-header.js b/packages/backend/src/apps/twitter/common/add-auth-header.js new file mode 100644 index 0000000..824181c --- /dev/null +++ b/packages/backend/src/apps/twitter/common/add-auth-header.js @@ -0,0 +1,39 @@ +import { URLSearchParams } from 'node:url'; +import oauthClient from './oauth-client.js'; + +const addAuthHeader = ($, requestConfig) => { + const { baseURL, url, method, data, params } = requestConfig; + + const token = { + key: $.auth.data?.accessToken, + secret: $.auth.data?.accessSecret, + }; + + const searchParams = new URLSearchParams(params); + const stringifiedParams = searchParams.toString(); + let fullUrl = `${baseURL}${url}`; + + // append the search params + if (stringifiedParams) { + fullUrl = `${fullUrl}?${stringifiedParams}`; + } + + const requestData = { + url: fullUrl, + method, + }; + + if (url === '/oauth/request_token') { + requestData.data = data; + } + + const authHeader = oauthClient($).toHeader( + oauthClient($).authorize(requestData, token) + ); + + requestConfig.headers.Authorization = authHeader.Authorization; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/twitter/common/get-current-user.js b/packages/backend/src/apps/twitter/common/get-current-user.js new file mode 100644 index 0000000..5dd99b4 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/2/users/me'); + const currentUser = response.data.data; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/twitter/common/get-user-by-username.js b/packages/backend/src/apps/twitter/common/get-user-by-username.js new file mode 100644 index 0000000..0e4b965 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-user-by-username.js @@ -0,0 +1,16 @@ +const getUserByUsername = async ($, username) => { + const response = await $.http.get(`/2/users/by/username/${username}`); + + if (response.data.errors) { + const errorMessages = response.data.errors + .map((error) => error.detail) + .join(' '); + + throw new Error(`Error occured while fetching user data: ${errorMessages}`); + } + + const user = response.data.data; + return user; +}; + +export default getUserByUsername; diff --git a/packages/backend/src/apps/twitter/common/get-user-followers.js b/packages/backend/src/apps/twitter/common/get-user-followers.js new file mode 100644 index 0000000..9b339f9 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-user-followers.js @@ -0,0 +1,36 @@ +import { URLSearchParams } from 'url'; +import omitBy from 'lodash/omitBy.js'; +import isEmpty from 'lodash/isEmpty.js'; + +const getUserFollowers = async ($, options) => { + let response; + + do { + const params = { + pagination_token: response?.data?.meta?.next_token, + }; + + const queryParams = new URLSearchParams(omitBy(params, isEmpty)); + + const requestPath = `/2/users/${options.userId}/followers${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = await $.http.get(requestPath); + + if (response.data?.errors) { + throw new Error(response.data.errors); + } + + if (response.data.meta.result_count > 0) { + for (const follower of response.data.data) { + $.pushTriggerItem({ + raw: follower, + meta: { internalId: follower.id }, + }); + } + } + } while (response.data.meta.next_token); +}; + +export default getUserFollowers; diff --git a/packages/backend/src/apps/twitter/common/get-user-tweets.js b/packages/backend/src/apps/twitter/common/get-user-tweets.js new file mode 100644 index 0000000..c63727a --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-user-tweets.js @@ -0,0 +1,56 @@ +import { URLSearchParams } from 'url'; +import omitBy from 'lodash/omitBy.js'; +import isEmpty from 'lodash/isEmpty.js'; +import getCurrentUser from './get-current-user.js'; +import getUserByUsername from './get-user-by-username.js'; + +const fetchTweets = async ($, username) => { + const user = await getUserByUsername($, username); + + let response; + + do { + const params = { + since_id: $.flow.lastInternalId, + pagination_token: response?.data?.meta?.next_token, + }; + + const queryParams = new URLSearchParams(omitBy(params, isEmpty)); + + const requestPath = `/2/users/${user.id}/tweets${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = await $.http.get(requestPath); + + if (response.data.meta.result_count > 0) { + response.data.data.forEach((tweet) => { + const dataItem = { + raw: tweet, + meta: { + internalId: tweet.id, + }, + }; + + $.pushTriggerItem(dataItem); + }); + } + } while (response.data.meta.next_token); + + return $.triggerOutput; +}; + +const getUserTweets = async ($, options) => { + let username; + + if (options.currentUser) { + const currentUser = await getCurrentUser($); + username = currentUser.username; + } else { + username = $.step.parameters.username; + } + + await fetchTweets($, username); +}; + +export default getUserTweets; diff --git a/packages/backend/src/apps/twitter/common/oauth-client.js b/packages/backend/src/apps/twitter/common/oauth-client.js new file mode 100644 index 0000000..d89c488 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/oauth-client.js @@ -0,0 +1,22 @@ +import crypto from 'crypto'; +import OAuth from 'oauth-1.0a'; + +const oauthClient = ($) => { + const consumerData = { + key: $.auth.data.consumerKey, + secret: $.auth.data.consumerSecret, + }; + + return new OAuth({ + consumer: consumerData, + signature_method: 'HMAC-SHA1', + hash_function(base_string, key) { + return crypto + .createHmac('sha1', key) + .update(base_string) + .digest('base64'); + }, + }); +}; + +export default oauthClient; diff --git a/packages/backend/src/apps/twitter/index.js b/packages/backend/src/apps/twitter/index.js new file mode 100644 index 0000000..adf28ca --- /dev/null +++ b/packages/backend/src/apps/twitter/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Twitter', + key: 'twitter', + iconUrl: '{BASE_URL}/apps/twitter/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/twitter/connection', + supportsConnections: true, + baseUrl: 'https://twitter.com', + apiBaseUrl: 'https://api.twitter.com', + primaryColor: '#1da1f2', + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, +}); diff --git a/packages/backend/src/apps/twitter/triggers/index.js b/packages/backend/src/apps/twitter/triggers/index.js new file mode 100644 index 0000000..9e36c62 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/index.js @@ -0,0 +1,6 @@ +import myTweets from './my-tweets/index.js'; +import newFollowerOfMe from './new-follower-of-me/index.js'; +import searchTweets from './search-tweets/index.js'; +import userTweets from './user-tweets/index.js'; + +export default [myTweets, newFollowerOfMe, searchTweets, userTweets]; diff --git a/packages/backend/src/apps/twitter/triggers/my-tweets/index.js b/packages/backend/src/apps/twitter/triggers/my-tweets/index.js new file mode 100644 index 0000000..756e43b --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/my-tweets/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import getUserTweets from '../../common/get-user-tweets.js'; + +export default defineTrigger({ + name: 'My tweets', + key: 'myTweets', + pollInterval: 15, + description: 'Triggers when you tweet something new.', + + async run($) { + await getUserTweets($, { currentUser: true }); + }, +}); diff --git a/packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.js b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.js new file mode 100644 index 0000000..9a3c06b --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.js @@ -0,0 +1,13 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import myFollowers from './my-followers.js'; + +export default defineTrigger({ + name: 'New follower of me', + key: 'newFollowerOfMe', + pollInterval: 15, + description: 'Triggers when you have a new follower.', + + async run($) { + await myFollowers($); + }, +}); diff --git a/packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.js b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.js new file mode 100644 index 0000000..41eb952 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.js @@ -0,0 +1,15 @@ +import getCurrentUser from '../../common/get-current-user.js'; +import getUserByUsername from '../../common/get-user-by-username.js'; +import getUserFollowers from '../../common/get-user-followers.js'; + +const myFollowers = async ($) => { + const { username } = await getCurrentUser($); + const user = await getUserByUsername($, username); + + const tweets = await getUserFollowers($, { + userId: user.id, + }); + return tweets; +}; + +export default myFollowers; diff --git a/packages/backend/src/apps/twitter/triggers/search-tweets/index.js b/packages/backend/src/apps/twitter/triggers/search-tweets/index.js new file mode 100644 index 0000000..1637290 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/search-tweets/index.js @@ -0,0 +1,22 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import searchTweets from './search-tweets.js'; + +export default defineTrigger({ + name: 'Search tweets', + key: 'searchTweets', + pollInterval: 15, + description: + 'Triggers when there is a new tweet containing a specific keyword, phrase, username or hashtag.', + arguments: [ + { + label: 'Search Term', + key: 'searchTerm', + type: 'string', + required: true, + }, + ], + + async run($) { + await searchTweets($); + }, +}); diff --git a/packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.js b/packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.js new file mode 100644 index 0000000..ddedda2 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.js @@ -0,0 +1,44 @@ +import qs from 'qs'; +import omitBy from 'lodash/omitBy.js'; +import isEmpty from 'lodash/isEmpty.js'; + +const searchTweets = async ($) => { + const searchTerm = $.step.parameters.searchTerm; + + let response; + + do { + const params = { + query: searchTerm, + since_id: $.flow.lastInternalId, + pagination_token: response?.data?.meta?.next_token, + }; + + const queryParams = qs.stringify(omitBy(params, isEmpty)); + + const requestPath = `/2/tweets/search/recent${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = await $.http.get(requestPath); + + if (response.data.errors) { + throw new Error(JSON.stringify(response.data.errors)); + } + + if (response.data.meta.result_count > 0) { + response.data.data.forEach((tweet) => { + const dataItem = { + raw: tweet, + meta: { + internalId: tweet.id, + }, + }; + + $.pushTriggerItem(dataItem); + }); + } + } while (response.data.meta.next_token); +}; + +export default searchTweets; diff --git a/packages/backend/src/apps/twitter/triggers/user-tweets/index.js b/packages/backend/src/apps/twitter/triggers/user-tweets/index.js new file mode 100644 index 0000000..c14c5a2 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/user-tweets/index.js @@ -0,0 +1,21 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; +import getUserTweets from '../../common/get-user-tweets.js'; + +export default defineTrigger({ + name: 'User tweets', + key: 'userTweets', + pollInterval: 15, + description: 'Triggers when a specific user tweet something new.', + arguments: [ + { + label: 'Username', + key: 'username', + type: 'string', + required: true, + }, + ], + + async run($) { + await getUserTweets($, { currentUser: false }); + }, +}); diff --git a/packages/backend/src/apps/typeform/assets/favicon.svg b/packages/backend/src/apps/typeform/assets/favicon.svg new file mode 100644 index 0000000..f0fabb1 --- /dev/null +++ b/packages/backend/src/apps/typeform/assets/favicon.svg @@ -0,0 +1,4 @@ + + Typeform + + diff --git a/packages/backend/src/apps/typeform/auth/generate-auth-url.js b/packages/backend/src/apps/typeform/auth/generate-auth-url.js new file mode 100644 index 0000000..4012e5e --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/generate-auth-url.js @@ -0,0 +1,20 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrl = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ).value; + + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: oauthRedirectUrl, + scope: authScope.join(' '), + }); + + const url = `${$.app.apiBaseUrl}/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/typeform/auth/index.js b/packages/backend/src/apps/typeform/auth/index.js new file mode 100644 index 0000000..81e6906 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/index.js @@ -0,0 +1,50 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; +import refreshToken from './refresh-token.js'; +import verifyWebhook from './verify-webhook.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/typeform/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Typeform OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, + verifyWebhook, +}; diff --git a/packages/backend/src/apps/typeform/auth/is-still-verified.js b/packages/backend/src/apps/typeform/auth/is-still-verified.js new file mode 100644 index 0000000..5f5c85e --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/is-still-verified.js @@ -0,0 +1,7 @@ +const isStillVerified = async ($) => { + await $.http.get('/me'); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/typeform/auth/refresh-token.js b/packages/backend/src/apps/typeform/auth/refresh-token.js new file mode 100644 index 0000000..a2d7a25 --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/refresh-token.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + refresh_token: $.auth.data.refreshToken, + scope: authScope.join(' '), + }); + + const { data } = await $.http.post('/oauth/token', params.toString()); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + tokenType: data.token_type, + refreshToken: data.refresh_token, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/typeform/auth/verify-credentials.js b/packages/backend/src/apps/typeform/auth/verify-credentials.js new file mode 100644 index 0000000..87ee6bd --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/verify-credentials.js @@ -0,0 +1,45 @@ +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrl = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ).value; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: $.auth.data.code, + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + redirect_uri: oauthRedirectUrl, + }); + + const { data: verifiedCredentials } = await $.http.post( + '/oauth/token', + params.toString() + ); + + const { + access_token: accessToken, + expires_in: expiresIn, + token_type: tokenType, + refresh_token: refreshToken, + } = verifiedCredentials; + + const { data: user } = await $.http.get('/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + await $.auth.set({ + accessToken, + expiresIn, + tokenType, + userId: user.user_id, + screenName: user.alias, + email: user.email, + refreshToken, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/typeform/auth/verify-webhook.js b/packages/backend/src/apps/typeform/auth/verify-webhook.js new file mode 100644 index 0000000..a1672ea --- /dev/null +++ b/packages/backend/src/apps/typeform/auth/verify-webhook.js @@ -0,0 +1,20 @@ +import crypto from 'crypto'; + +import appConfig from '../../../config/app.js'; + +const verifyWebhook = async ($) => { + const signature = $.request.headers['typeform-signature']; + const isValid = verifySignature(signature, $.request.rawBody.toString()); + + return isValid; +}; + +const verifySignature = function (receivedSignature, payload) { + const hash = crypto + .createHmac('sha256', appConfig.webhookSecretKey) + .update(payload) + .digest('base64'); + return receivedSignature === `sha256=${hash}`; +}; + +export default verifyWebhook; diff --git a/packages/backend/src/apps/typeform/common/add-auth-header.js b/packages/backend/src/apps/typeform/common/add-auth-header.js new file mode 100644 index 0000000..bf9ea77 --- /dev/null +++ b/packages/backend/src/apps/typeform/common/add-auth-header.js @@ -0,0 +1,10 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + const authorizationHeader = `Bearer ${$.auth.data.accessToken}`; + requestConfig.headers.Authorization = authorizationHeader; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/typeform/common/auth-scope.js b/packages/backend/src/apps/typeform/common/auth-scope.js new file mode 100644 index 0000000..1063656 --- /dev/null +++ b/packages/backend/src/apps/typeform/common/auth-scope.js @@ -0,0 +1,12 @@ +const authScope = [ + 'forms:read', + 'forms:write', + 'webhooks:read', + 'webhooks:write', + 'responses:read', + 'accounts:read', + 'workspaces:read', + 'offline', +]; + +export default authScope; diff --git a/packages/backend/src/apps/typeform/dynamic-data/index.js b/packages/backend/src/apps/typeform/dynamic-data/index.js new file mode 100644 index 0000000..0a58430 --- /dev/null +++ b/packages/backend/src/apps/typeform/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listForms from './list-forms/index.js'; + +export default [listForms]; diff --git a/packages/backend/src/apps/typeform/dynamic-data/list-forms/index.js b/packages/backend/src/apps/typeform/dynamic-data/list-forms/index.js new file mode 100644 index 0000000..93e56f5 --- /dev/null +++ b/packages/backend/src/apps/typeform/dynamic-data/list-forms/index.js @@ -0,0 +1,21 @@ +export default { + name: 'List forms', + key: 'listForms', + + async run($) { + const forms = { + data: [], + }; + + const response = await $.http.get('/forms'); + + forms.data = response.data.items.map((form) => { + return { + value: form.id, + name: form.title, + }; + }); + + return forms; + }, +}; diff --git a/packages/backend/src/apps/typeform/index.js b/packages/backend/src/apps/typeform/index.js new file mode 100644 index 0000000..0e8832a --- /dev/null +++ b/packages/backend/src/apps/typeform/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Typeform', + key: 'typeform', + iconUrl: '{BASE_URL}/apps/typeform/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/typeform/connection', + supportsConnections: true, + baseUrl: 'https://typeform.com', + apiBaseUrl: 'https://api.typeform.com', + primaryColor: '#262627', + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/typeform/triggers/index.js b/packages/backend/src/apps/typeform/triggers/index.js new file mode 100644 index 0000000..38b6a20 --- /dev/null +++ b/packages/backend/src/apps/typeform/triggers/index.js @@ -0,0 +1,3 @@ +import newEntry from './new-entry/index.js'; + +export default [newEntry]; diff --git a/packages/backend/src/apps/typeform/triggers/new-entry/index.js b/packages/backend/src/apps/typeform/triggers/new-entry/index.js new file mode 100644 index 0000000..2b666ee --- /dev/null +++ b/packages/backend/src/apps/typeform/triggers/new-entry/index.js @@ -0,0 +1,101 @@ +import Crypto from 'crypto'; +import appConfig from '../../../../config/app.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New entry', + key: 'newEntry', + type: 'webhook', + description: 'Triggers when a new form is submitted.', + arguments: [ + { + label: 'Form', + key: 'formId', + type: 'dropdown', + required: true, + description: 'Pick a form to receive submissions.', + variables: false, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listForms', + }, + ], + }, + }, + ], + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const { data: form } = await $.http.get( + `/forms/${$.step.parameters.formId}` + ); + + const { data: responses } = await $.http.get( + `/forms/${$.step.parameters.formId}/responses` + ); + + const lastResponse = responses.items[0]; + + if (!lastResponse) { + return; + } + + const computedWebhookEvent = { + event_type: 'form_response', + form_response: { + form_id: form.id, + token: lastResponse.token, + landed_at: lastResponse.landed_at, + submitted_at: lastResponse.submitted_at, + definition: { + id: $.step.parameters.formId, + title: form.title, + fields: form?.fields, + }, + answers: lastResponse.answers, + }, + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.form_response.token, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const subscriptionPayload = { + enabled: true, + url: $.webhookUrl, + secret: appConfig.webhookSecretKey, + }; + + await $.http.put( + `/forms/${$.step.parameters.formId}/webhooks/${$.flow.id}`, + subscriptionPayload + ); + }, + + async unregisterHook($) { + await $.http.delete( + `/forms/${$.step.parameters.formId}/webhooks/${$.flow.id}` + ); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/create-waiter/index.js b/packages/backend/src/apps/virtualq/actions/create-waiter/index.js new file mode 100644 index 0000000..dc4ec20 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/create-waiter/index.js @@ -0,0 +1,153 @@ +import defineAction from '../../../../helpers/define-action.js'; +import isPlainObject from 'lodash/isPlainObject.js'; + +export default defineAction({ + name: 'Create waiter', + key: 'createWaiter', + description: 'Enqueues a waiter to the line with the selected line.', + arguments: [ + { + label: 'Line', + key: 'lineId', + type: 'dropdown', + required: true, + variables: true, + description: 'The line to join', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLines', + }, + ], + }, + }, + { + label: 'Phone', + key: 'phone', + type: 'string', + required: true, + variables: true, + description: + "The caller's phone number including country code (for example +4017111112233)", + }, + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + description: + 'Option describing if the waiter expects a callback or will receive a text message', + required: true, + variables: true, + options: [ + { label: 'Call back', value: 'CallBack' }, + { label: 'Call in', value: 'CallIn' }, + ], + }, + { + label: 'Source', + key: 'source', + type: 'dropdown', + description: 'Option describing the source where the caller came from', + required: true, + variables: true, + options: [ + { label: 'Widget', value: 'Widget' }, + { label: 'Phone', value: 'Phone' }, + { label: 'Mobile', value: 'Mobile' }, + { label: 'App', value: 'App' }, + { label: 'Other', value: 'Other' }, + ], + }, + { + label: 'Appointment', + key: 'appointment', + type: 'dropdown', + required: true, + variables: true, + value: false, + description: + 'If set to true, then this marks this as an appointment. If appointment_time is set, this is automatically set to true.', + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + additionalFields: { + type: 'query', + name: 'getDynamicFields', + arguments: [ + { + name: 'key', + value: 'listAppointmentFields', + }, + { + name: 'parameters.appointment', + value: '{parameters.appointment}', + }, + ], + }, + }, + { + label: 'Service phone to call', + key: 'servicePhoneToCall', + type: 'string', + description: + "If set, callback uses this number instead of the line's service phone number", + required: false, + variables: true, + }, + { + label: 'Properties', + key: 'properties', + type: 'string', + required: false, + variables: false, + valueType: 'parse', + description: 'JSON for the additional properties.', + value: '{}', + }, + ], + async run($) { + const { + lineId, + phone, + channel, + source, + appointment, + appointmentTime, + servicePhoneToCall, + properties = {}, + } = $.step.parameters; + + const body = { + data: { + type: 'waiters', + attributes: { + line_id: lineId, + phone, + channel, + source, + appointment, + service_phone_to_call: servicePhoneToCall, + properties, + }, + }, + }; + + if (appointment) { + body.data.attributes.appointmentTime = appointmentTime; + } + + if (!isPlainObject(properties)) { + throw new Error( + `The "properties" field must have a valid JSON. The current value: ${properties}` + ); + } + + const { data } = await $.http.post('/v2/waiters', body); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/delete-waiter/index.js b/packages/backend/src/apps/virtualq/actions/delete-waiter/index.js new file mode 100644 index 0000000..3922046 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/delete-waiter/index.js @@ -0,0 +1,34 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Delete waiter', + key: 'deleteWaiter', + description: + 'Cancels waiting. The provided waiter will be removed from the queue.', + arguments: [ + { + label: 'Waiter', + key: 'waiterId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWaiters', + }, + ], + }, + }, + ], + async run($) { + const waiterId = $.step.parameters.waiterId; + + const { data } = await $.http.delete(`/v2/waiters/${waiterId}`); + + $.setActionItem({ raw: { output: data } }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/index.js b/packages/backend/src/apps/virtualq/actions/index.js new file mode 100644 index 0000000..96dd545 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/index.js @@ -0,0 +1,6 @@ +import createWaiter from './create-waiter/index.js'; +import deleteWaiter from './delete-waiter/index.js'; +import showWaiter from './show-waiter/index.js'; +import updateWaiter from './update-waiter/index.js'; + +export default [createWaiter, deleteWaiter, showWaiter, updateWaiter]; diff --git a/packages/backend/src/apps/virtualq/actions/show-waiter/index.js b/packages/backend/src/apps/virtualq/actions/show-waiter/index.js new file mode 100644 index 0000000..1166a18 --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/show-waiter/index.js @@ -0,0 +1,33 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Show waiter', + key: 'showWaiter', + description: 'Returns the complete waiter information.', + arguments: [ + { + label: 'Waiter', + key: 'waiterId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWaiters', + }, + ], + }, + }, + ], + async run($) { + const waiterId = $.step.parameters.waiterId; + + const { data } = await $.http.get(`/v2/waiters/${waiterId}`); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/actions/update-waiter/index.js b/packages/backend/src/apps/virtualq/actions/update-waiter/index.js new file mode 100644 index 0000000..c00631d --- /dev/null +++ b/packages/backend/src/apps/virtualq/actions/update-waiter/index.js @@ -0,0 +1,178 @@ +import defineAction from '../../../../helpers/define-action.js'; +import isPlainObject from 'lodash/isPlainObject.js'; + +export default defineAction({ + name: 'Update waiter', + key: 'updateWaiter', + description: 'Updates a waiter to the line with the selected line.', + arguments: [ + { + label: 'Waiter', + key: 'waiterId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listWaiters', + }, + ], + }, + }, + { + label: 'Line', + key: 'lineId', + type: 'dropdown', + required: false, + variables: true, + description: 'Used to find caller if 0 is used for waiter field', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLines', + }, + ], + }, + }, + { + label: 'Phone', + key: 'phone', + type: 'string', + required: false, + variables: true, + description: 'Used to find caller if 0 is used for waiter field', + }, + { + label: 'Estimated waiting time', + key: 'serviceWaiterEwt', + type: 'string', + description: 'EWT as calculated by the service', + required: false, + variables: true, + }, + { + label: 'State', + key: 'serviceWaiterState', + type: 'dropdown', + description: 'State of caller in the call center', + required: false, + variables: true, + options: [ + { label: 'Waiting', value: 'Waiting' }, + { label: 'Connected', value: 'Connected' }, + { label: 'Transferred', value: 'Transferred' }, + { label: 'Timeout', value: 'Timeout' }, + { label: 'Canceled', value: 'Canceled' }, + ], + }, + { + label: 'Wait time', + key: 'waitTimeWhenUp', + type: 'string', + description: 'Wait time in seconds before being transferred to agent', + required: false, + variables: true, + }, + { + label: 'Talk time', + key: 'talkTime', + type: 'string', + description: 'Time in seconds spent talking with Agent', + required: false, + variables: true, + }, + { + label: 'Agent', + key: 'agentId', + type: 'string', + description: 'Agent where call was transferred to', + required: false, + variables: true, + }, + { + label: 'Service phone to call', + key: 'servicePhoneToCall', + type: 'string', + description: + "If set, callback uses this number instead of the line's service phone number", + required: false, + variables: true, + }, + { + label: 'Properties', + key: 'properties', + type: 'string', + required: false, + variables: false, + valueType: 'parse', + description: 'JSON for the additional properties.', + }, + ], + + async run($) { + const { + waiterId, + lineId, + phone, + serviceWaiterEwt, + serviceWaiterState, + waitTimeWhenUp, + talkTime, + agentId, + servicePhoneToCall, + properties, + } = $.step.parameters; + + const body = { + data: { + type: 'waiters', + attributes: { + line_id: lineId, + phone, + service_phone_to_call: servicePhoneToCall, + }, + }, + }; + + if (serviceWaiterEwt) { + body.data.attributes.service_waiter_ewt = serviceWaiterEwt; + } + + if (serviceWaiterState) { + body.data.attributes.service_waiter_state = serviceWaiterState; + } + + if (talkTime) { + body.data.attributes.talk_time = talkTime; + } + + if (agentId) { + body.data.attributes.agent_id = agentId; + } + + if (waitTimeWhenUp) { + body.data.attributes.wait_time_when_up = waitTimeWhenUp; + } + + if (properties) { + if (!isPlainObject(properties)) { + throw new Error( + `The "properties" field must have a valid JSON. The current value: ${properties}` + ); + } + + body.data.attributes.properties = properties; + } + + const { data } = await $.http.put(`/v2/waiters/${waiterId}`, body); + + $.setActionItem({ raw: data }); + }, +}); diff --git a/packages/backend/src/apps/virtualq/assets/favicon.svg b/packages/backend/src/apps/virtualq/assets/favicon.svg new file mode 100644 index 0000000..41162b4 --- /dev/null +++ b/packages/backend/src/apps/virtualq/assets/favicon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/backend/src/apps/virtualq/auth/index.js b/packages/backend/src/apps/virtualq/auth/index.js new file mode 100644 index 0000000..4165923 --- /dev/null +++ b/packages/backend/src/apps/virtualq/auth/index.js @@ -0,0 +1,21 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API key of the VirtualQ API service.', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/virtualq/auth/is-still-verified.js b/packages/backend/src/apps/virtualq/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/virtualq/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/virtualq/auth/verify-credentials.js b/packages/backend/src/apps/virtualq/auth/verify-credentials.js new file mode 100644 index 0000000..1899f9e --- /dev/null +++ b/packages/backend/src/apps/virtualq/auth/verify-credentials.js @@ -0,0 +1,14 @@ +const verifyCredentials = async ($) => { + const response = await $.http.get('/v2/call_centers'); + + const callCenterNames = response.data.data + .map((callCenter) => callCenter.attributes.name) + .join(' - '); + + await $.auth.set({ + screenName: callCenterNames, + apiKey: $.auth.data.apiKey, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/virtualq/common/add-auth-header.js b/packages/backend/src/apps/virtualq/common/add-auth-header.js new file mode 100644 index 0000000..91d34fe --- /dev/null +++ b/packages/backend/src/apps/virtualq/common/add-auth-header.js @@ -0,0 +1,15 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.apiKey) { + requestConfig.headers['X-API-Key'] = $.auth.data.apiKey; + } + + if (requestConfig.method === 'post' || requestConfig.method === 'put') { + requestConfig.headers['Content-Type'] = 'application/vnd.api+json'; + } + + requestConfig.headers['X-Keys-Format'] = 'underscore'; + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/virtualq/dynamic-data/index.js b/packages/backend/src/apps/virtualq/dynamic-data/index.js new file mode 100644 index 0000000..e8aed5d --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-data/index.js @@ -0,0 +1,4 @@ +import listLines from './list-lines/index.js'; +import listWaiters from './list-waiters/index.js'; + +export default [listLines, listWaiters]; diff --git a/packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js b/packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js new file mode 100644 index 0000000..de2cf23 --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-data/list-lines/index.js @@ -0,0 +1,15 @@ +export default { + name: 'List lines', + key: 'listLines', + + async run($) { + const response = await $.http.get('/v2/lines'); + + const lines = response.data.data.map((line) => ({ + value: line.id, + name: line.attributes.name, + })); + + return { data: lines }; + }, +}; diff --git a/packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js b/packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js new file mode 100644 index 0000000..03aa1eb --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-data/list-waiters/index.js @@ -0,0 +1,15 @@ +export default { + name: 'List waiters', + key: 'listWaiters', + + async run($) { + const response = await $.http.get('/v2/waiters'); + + const waiters = response.data.data.map((waiter) => ({ + value: waiter.id, + name: `${waiter.attributes.phone} @ ${waiter.attributes.line.name}`, + })); + + return { data: waiters }; + }, +}; diff --git a/packages/backend/src/apps/virtualq/dynamic-fields/index.js b/packages/backend/src/apps/virtualq/dynamic-fields/index.js new file mode 100644 index 0000000..9473451 --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-fields/index.js @@ -0,0 +1,3 @@ +import listAppointmentFields from './list-appointment-fields/index.js'; + +export default [listAppointmentFields]; diff --git a/packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js b/packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js new file mode 100644 index 0000000..8ce2a3c --- /dev/null +++ b/packages/backend/src/apps/virtualq/dynamic-fields/list-appointment-fields/index.js @@ -0,0 +1,20 @@ +export default { + name: 'List appointment fields', + key: 'listAppointmentFields', + + async run($) { + if ($.step.parameters.appointment) { + return [ + { + label: 'Appointment Time', + key: 'appointmentTime', + type: 'string', + required: true, + variables: true, + description: + 'Overrides the estimated up time with this time. Specify number of seconds since 1970 UTC.', + }, + ]; + } + }, +}; diff --git a/packages/backend/src/apps/virtualq/index.js b/packages/backend/src/apps/virtualq/index.js new file mode 100644 index 0000000..3f9910e --- /dev/null +++ b/packages/backend/src/apps/virtualq/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; +import dynamicFields from './dynamic-fields/index.js'; + +export default defineApp({ + name: 'VirtualQ', + key: 'virtualq', + iconUrl: '{BASE_URL}/apps/virtualq/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/virtualq/connection', + supportsConnections: true, + baseUrl: 'https://www.virtualq.tech', + apiBaseUrl: 'https://api.virtualq.tech/api/', + primaryColor: '#2E3D59', + beforeRequest: [addAuthHeader], + auth, + actions, + dynamicData, + dynamicFields, +}); diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-case/fields.js b/packages/backend/src/apps/vtiger-crm/actions/create-case/fields.js new file mode 100644 index 0000000..cc03b26 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-case/fields.js @@ -0,0 +1,408 @@ +export const fields = [ + { + label: 'Summary', + key: 'summary', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Record Currency', + key: 'recordCurrencyId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRecordCurrencies', + }, + ], + }, + }, + { + label: 'Case Title', + key: 'caseTitle', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.status', + value: 'casestatus', + }, + ], + }, + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.priority', + value: 'casepriority', + }, + ], + }, + }, + { + label: 'Contact', + key: 'contactId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContacts', + }, + ], + }, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Group', + key: 'groupId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listGroups', + }, + ], + }, + }, + { + label: 'Assigned To', + key: 'assignedTo', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Product', + key: 'productId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listProducts', + }, + ], + }, + }, + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.channel', + value: 'casechannel', + }, + ], + }, + }, + { + label: 'Resolution', + key: 'resolution', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Category', + key: 'category', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.category', + value: 'impact_type', + }, + ], + }, + }, + { + label: 'Sub Category', + key: 'subCategory', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.subCategory', + value: 'impact_area', + }, + ], + }, + }, + { + label: 'Resolution Type', + key: 'resolutionType', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.resolutionType', + value: 'resolution_type', + }, + ], + }, + }, + { + label: 'Deferred Date', + key: 'deferredDate', + type: 'string', + required: false, + description: 'format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Service Contract', + key: 'serviceContractId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listServiceContracts', + }, + ], + }, + }, + { + label: 'Asset', + key: 'assetId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listAssets', + }, + ], + }, + }, + { + label: 'SLA', + key: 'slaId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSlaNames', + }, + ], + }, + }, + { + label: 'Is Billable', + key: 'isBillable', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'True', value: '1' }, + { label: 'False', value: '-1' }, + ], + }, + { + label: 'Service', + key: 'service', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listServices', + }, + ], + }, + }, + { + label: 'Rate', + key: 'rate', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Service Type', + key: 'serviceType', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.serviceType', + value: 'servicetype', + }, + ], + }, + }, + { + label: 'Service Location', + key: 'serviceLocation', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCaseOptions', + }, + { + name: 'parameters.serviceLocation', + value: 'servicelocation', + }, + ], + }, + }, + { + label: 'Work Location', + key: 'workLocation', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-case/index.js b/packages/backend/src/apps/vtiger-crm/actions/create-case/index.js new file mode 100644 index 0000000..1c931d6 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-case/index.js @@ -0,0 +1,78 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create case', + key: 'createCase', + description: 'Create a new case.', + arguments: fields, + + async run($) { + const { + summary, + recordCurrencyId, + caseTitle, + status, + priority, + contactId, + organizationId, + groupId, + assignedTo, + productId, + channel, + resolution, + category, + subCategory, + resolutionType, + deferredDate, + serviceContractId, + assetId, + slaId, + isBillable, + service, + rate, + serviceType, + serviceLocation, + workLocation, + } = $.step.parameters; + + const elementData = { + description: summary, + record_currency_id: recordCurrencyId, + title: caseTitle, + casestatus: status, + casepriority: priority, + contact_id: contactId, + parent_id: organizationId, + group_id: groupId, + assigned_user_id: assignedTo, + product_id: productId, + casechannel: channel, + resolution: resolution, + impact_type: category, + impact_area: subCategory, + resolution_type: resolutionType, + deferred_date: deferredDate, + servicecontract_id: serviceContractId, + asset_id: assetId, + slaid: slaId, + is_billable: isBillable, + billing_service: service, + rate: rate, + servicetype: serviceType, + servicelocation: serviceLocation, + work_location: workLocation, + }; + + const body = { + operation: 'create', + sessionName: $.auth.data.sessionName, + element: JSON.stringify(elementData), + elementType: 'Cases', + }; + + const response = await $.http.post('/webservice.php', body); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-contact/fields.js b/packages/backend/src/apps/vtiger-crm/actions/create-contact/fields.js new file mode 100644 index 0000000..c3725d2 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-contact/fields.js @@ -0,0 +1,649 @@ +export const fields = [ + { + label: 'Salutation', + key: 'salutation', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Mr.', value: 'Mr.' }, + { label: 'Ms.', value: 'Ms.' }, + { label: 'Mrs.', value: 'Mrs.' }, + { label: 'Dr.', value: 'Dr.' }, + { label: 'Prof.', value: 'Prof.' }, + ], + }, + { + label: 'First Name', + key: 'firstName', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Last Name', + key: 'lastName', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Primary Email', + key: 'primaryEmail', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Office Phone', + key: 'officePhone', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Mobile Phone', + key: 'mobilePhone', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Home Phone', + key: 'homePhone', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Date of Birth', + key: 'dateOfBirth', + type: 'string', + required: false, + description: 'format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Fax', + key: 'fax', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Title', + key: 'title', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.title', + value: 'listContactOptions', + }, + ], + }, + }, + { + label: 'Department', + key: 'department', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Reports To', + key: 'reportsTo', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContacts', + }, + ], + }, + }, + { + label: 'Lead Source', + key: 'leadSource', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.leadSource', + value: 'leadsource', + }, + ], + }, + }, + { + label: 'Secondary Email', + key: 'secondaryEmail', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Assigned To', + key: 'assignedTo', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Do Not Call', + key: 'doNotCall', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'True', value: '1' }, + { label: 'False', value: '-1' }, + ], + }, + { + label: 'Notify Owner', + key: 'notifyOwner', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'True', value: '1' }, + { label: 'False', value: '-1' }, + ], + }, + { + label: 'Twitter Username', + key: 'twitterUsername', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'SLA', + key: 'slaId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSlaNames', + }, + ], + }, + }, + { + label: 'Lifecycle Stage', + key: 'lifecycleStage', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.lifecycleStage', + value: 'contacttype', + }, + ], + }, + }, + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.status', + value: 'contactstatus', + }, + ], + }, + }, + { + label: 'Happiness Rating', + key: 'happinessRating', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.happinessRating', + value: 'happiness_rating', + }, + ], + }, + }, + { + label: 'Record Currency', + key: 'recordCurrencyId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRecordCurrencies', + }, + ], + }, + }, + { + label: 'Referred By', + key: 'referredBy', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContacts', + }, + ], + }, + }, + { + label: 'Email Opt-in', + key: 'emailOptin', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.emailOptin', + value: 'emailoptin', + }, + ], + }, + }, + { + label: 'SMS Opt-in', + key: 'smsOptin', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.smsOptin', + value: 'smsoptin', + }, + ], + }, + }, + { + label: 'Language', + key: 'language', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.language', + value: 'language', + }, + ], + }, + }, + { + label: 'Source Campaign', + key: 'sourceCampaignId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCampaignSources', + }, + ], + }, + }, + { + label: 'Portal User', + key: 'portalUser', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'True', value: '1' }, + { label: 'False', value: '-1' }, + ], + }, + { + label: 'Support Start Date', + key: 'supportStartDate', + type: 'string', + required: false, + description: 'format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Support End Date', + key: 'supportEndDate', + type: 'string', + required: false, + description: 'format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Other Country', + key: 'otherCountry', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.otherCountry', + value: 'othercountry', + }, + ], + }, + }, + { + label: 'Mailing Country', + key: 'mailingCountry', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.mailingCountry', + value: 'mailingcountry', + }, + ], + }, + }, + { + label: 'Mailing Street', + key: 'mailingStreet', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Other Street', + key: 'otherStreet', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Mailing PO Box', + key: 'mailingPoBox', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Other PO Box', + key: 'otherPoBox', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Mailing City', + key: 'mailingCity', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Other City', + key: 'otherCity', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Mailing State', + key: 'mailingState', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.mailingState', + value: 'mailingstate', + }, + ], + }, + }, + { + label: 'Other State', + key: 'otherState', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContactOptions', + }, + { + name: 'parameters.otherState', + value: 'otherstate', + }, + ], + }, + }, + { + label: 'Mailing Zip', + key: 'mailingZip', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Other Zip', + key: 'otherZip', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Contact Image', + key: 'contactImage', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Linkedin URL', + key: 'linkedinUrl', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Linkedin Followers', + key: 'linkedinFollowers', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Facebook URL', + key: 'facebookUrl', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Facebook Followers', + key: 'facebookFollowers', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-contact/index.js b/packages/backend/src/apps/vtiger-crm/actions/create-contact/index.js new file mode 100644 index 0000000..5432e82 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-contact/index.js @@ -0,0 +1,129 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create contact', + key: 'createContact', + description: 'Create a new contact.', + arguments: fields, + + async run($) { + const { + salutation, + firstName, + lastName, + primaryEmail, + officePhone, + mobilePhone, + homePhone, + dateOfBirth, + fax, + organizationId, + title, + department, + reportsTo, + leadSource, + secondaryEmail, + assignedTo, + doNotCall, + notifyOwner, + twitterUsername, + slaId, + lifecycleStage, + status, + happinessRating, + recordCurrencyId, + referredBy, + emailOptin, + smsOptin, + language, + sourceCampaignId, + portalUser, + supportStartDate, + supportEndDate, + otherCountry, + mailingCountry, + mailingStreet, + otherStreet, + mailingPoBox, + otherPoBox, + mailingCity, + otherCity, + mailingState, + otherState, + mailingZip, + otherZip, + description, + contactImage, + linkedinUrl, + linkedinFollowers, + facebookUrl, + facebookFollowers, + } = $.step.parameters; + + const elementData = { + salutationtype: salutation, + firstname: firstName, + lastname: lastName, + email: primaryEmail, + phone: officePhone, + mobile: mobilePhone, + homephone: homePhone, + birthday: dateOfBirth, + fax: fax, + account_id: organizationId, + title: title, + department: department, + contact_id: reportsTo, + leadsource: leadSource, + secondaryemail: secondaryEmail, + assigned_user_id: assignedTo || $.auth.data.userId, + donotcall: doNotCall, + notify_owner: notifyOwner, + emailoptout: emailOptin, + primary_twitter: twitterUsername, + slaid: slaId, + contacttype: lifecycleStage, + contactstatus: status, + happiness_rating: happinessRating, + record_currency_id: recordCurrencyId, + referred_by: referredBy, + emailoptin: emailOptin, + smsoptin: smsOptin, + language: language, + source_campaign: sourceCampaignId, + portal: portalUser, + support_start_date: supportStartDate, + support_end_date: supportEndDate, + othercountry: otherCountry, + mailingcountry: mailingCountry, + mailingstreet: mailingStreet, + otherstreet: otherStreet, + mailingpobox: mailingPoBox, + otherpobox: otherPoBox, + mailingcity: mailingCity, + othercity: otherCity, + mailingstate: mailingState, + otherstate: otherState, + mailingzip: mailingZip, + otherzip: otherZip, + description: description, + imagename: contactImage, + primary_linkedin: linkedinUrl, + followers_linkedin: linkedinFollowers, + primary_facebook: facebookUrl, + followers_facebook: facebookFollowers, + }; + + const body = { + operation: 'create', + sessionName: $.auth.data.sessionName, + element: JSON.stringify(elementData), + elementType: 'Contacts', + }; + + const response = await $.http.post('/webservice.php', body); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-lead/fields.js b/packages/backend/src/apps/vtiger-crm/actions/create-lead/fields.js new file mode 100644 index 0000000..9f02573 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-lead/fields.js @@ -0,0 +1,395 @@ +export const fields = [ + { + label: 'Salutation', + key: 'salutation', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'Mr.', value: 'Mr.' }, + { label: 'Ms.', value: 'Ms.' }, + { label: 'Mrs.', value: 'Mrs.' }, + { label: 'Dr.', value: 'Dr.' }, + { label: 'Prof.', value: 'Prof.' }, + ], + }, + { + label: 'First Name', + key: 'firstName', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Last Name', + key: 'lastName', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Company', + key: 'company', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Primary Email', + key: 'primaryEmail', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Office Phone', + key: 'officePhone', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Designation', + key: 'designation', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.designation', + value: 'designation', + }, + ], + }, + }, + { + label: 'Mobile Phone', + key: 'mobilePhone', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Industry', + key: 'industry', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.industry', + value: 'industry', + }, + ], + }, + }, + { + label: 'Website', + key: 'website', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Annual Revenue', + key: 'annualRevenue', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Lead Source', + key: 'leadSource', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.leadSource', + value: 'leadsource', + }, + ], + }, + }, + { + label: 'Lead Status', + key: 'leadStatus', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.leadStatus', + value: 'leadstatus', + }, + ], + }, + }, + { + label: 'Assigned To', + key: 'assignedTo', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Fax', + key: 'fax', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Number of Employees', + key: 'numberOfEmployees', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Twitter Username', + key: 'twitterUsername', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Record Currency', + key: 'recordCurrencyId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRecordCurrencies', + }, + ], + }, + }, + { + label: 'Email Opt-in', + key: 'emailOptin', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.emailOptin', + value: 'emailoptin', + }, + ], + }, + }, + { + label: 'SMS Opt-in', + key: 'smsOptin', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.smsOptin', + value: 'smsoptin', + }, + ], + }, + }, + { + label: 'Language', + key: 'language', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.language', + value: 'language', + }, + ], + }, + }, + { + label: 'Source Campaign', + key: 'sourceCampaignId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCampaignSources', + }, + ], + }, + }, + { + label: 'Country', + key: 'country', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.country', + value: 'country', + }, + ], + }, + }, + { + label: 'Street', + key: 'street', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'PO Box', + key: 'poBox', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Postal Code', + key: 'postalCode', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'City', + key: 'city', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'State', + key: 'state', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listLeadOptions', + }, + { + name: 'parameters.state', + value: 'state', + }, + ], + }, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Lead Image', + key: 'leadImage', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-lead/index.js b/packages/backend/src/apps/vtiger-crm/actions/create-lead/index.js new file mode 100644 index 0000000..a4865cb --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-lead/index.js @@ -0,0 +1,88 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create lead', + key: 'createLead', + description: 'Create a new lead.', + arguments: fields, + + async run($) { + const { + salutation, + firstName, + lastName, + company, + primaryEmail, + officePhone, + designation, + mobilePhone, + industry, + website, + annualRevenue, + leadSource, + leadStatus, + assignedTo, + fax, + numberOfEmployees, + twitterUsername, + recordCurrencyId, + emailOptin, + smsOptin, + language, + sourceCampaignId, + country, + street, + poBox, + postalCode, + city, + state, + description, + leadImage, + } = $.step.parameters; + + const elementData = { + salutationtype: salutation, + firstname: firstName, + lastname: lastName, + company: company, + email: primaryEmail, + phone: officePhone, + designation: designation, + mobile: mobilePhone, + industry: industry, + website: website, + annualrevenue: annualRevenue, + leadsource: leadSource, + leadstatus: leadStatus, + assigned_user_id: assignedTo || $.auth.data.userId, + fax: fax, + noofemployees: numberOfEmployees, + primary_twitter: twitterUsername, + record_currency_id: recordCurrencyId, + emailoptin: emailOptin, + smsoptin: smsOptin, + language: language, + source_campaign: sourceCampaignId, + country: country, + lane: street, + pobox: poBox, + code: postalCode, + city: city, + state: state, + description: description, + imagename: leadImage, + }; + + const body = { + operation: 'create', + sessionName: $.auth.data.sessionName, + element: JSON.stringify(elementData), + elementType: 'Leads', + }; + + const response = await $.http.post('/webservice.php', body); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-opportunity/fields.js b/packages/backend/src/apps/vtiger-crm/actions/create-opportunity/fields.js new file mode 100644 index 0000000..a252da8 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-opportunity/fields.js @@ -0,0 +1,244 @@ +export const fields = [ + { + label: 'Deal Name', + key: 'dealName', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Amount', + key: 'amount', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Contact', + key: 'contactId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContacts', + }, + ], + }, + }, + { + label: 'Expected Close Date', + key: 'expectedCloseDate', + type: 'string', + required: true, + description: 'Format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Pipeline', + key: 'pipeline', + type: 'dropdown', + required: true, + value: 'Standart', + description: '', + variables: true, + options: [{ label: 'Standart', value: 'Standart' }], + }, + { + label: 'Sales Stage', + key: 'salesStage', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOpportunityOptions', + }, + { + name: 'parameters.salesStage', + value: 'sales_stage', + }, + ], + }, + }, + { + label: 'Assigned To', + key: 'assignedTo', + type: 'string', + required: false, + description: 'Default is the id of the account connected to Automatisch.', + variables: true, + }, + { + label: 'Lead Source', + key: 'leadSource', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOpportunityOptions', + }, + { + name: 'parameters.leadSource', + value: 'leadsource', + }, + ], + }, + }, + { + label: 'Next Step', + key: 'nextStep', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOpportunityOptions', + }, + { + name: 'parameters.type', + value: 'opportunity_type', + }, + ], + }, + }, + { + label: 'Probability', + key: 'probability', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Campaign Source', + key: 'campaignSourceId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listCampaignSources', + }, + ], + }, + }, + { + label: 'Weighted Revenue', + key: 'weightedRevenue', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Adjusted Amount', + key: 'adjustedAmount', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Lost Reason', + key: 'lostReason', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOpportunityOptions', + }, + { + name: 'parameters.lostReason', + value: 'lost_reason', + }, + ], + }, + }, + { + label: 'Record Currency', + key: 'recordCurrencyId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRecordCurrencies', + }, + ], + }, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + description: '', + variables: true, + }, +]; diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-opportunity/index.js b/packages/backend/src/apps/vtiger-crm/actions/create-opportunity/index.js new file mode 100644 index 0000000..12d2f7c --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-opportunity/index.js @@ -0,0 +1,64 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create opportunity', + key: 'createOpportunity', + description: 'Create a new opportunity.', + arguments: fields, + + async run($) { + const { + dealName, + amount, + organizationId, + contactId, + expectedCloseDate, + pipeline, + salesStage, + assignedTo, + leadSource, + nextStep, + type, + probability, + campaignSourceId, + weightedRevenue, + adjustedAmount, + lostReason, + recordCurrencyId, + description, + } = $.step.parameters; + + const elementData = { + potentialname: dealName, + amount, + related_to: organizationId, + contact_id: contactId, + closingdate: expectedCloseDate, + pipeline, + sales_stage: salesStage, + assigned_user_id: assignedTo || $.auth.data.userId, + leadsource: leadSource, + nextstep: nextStep, + opportunity_type: type, + probability: probability, + campaignid: campaignSourceId, + forecast_amount: weightedRevenue, + adjusted_amount: adjustedAmount, + lost_reason: lostReason, + record_currency_id: recordCurrencyId, + description, + }; + + const body = { + operation: 'create', + sessionName: $.auth.data.sessionName, + element: JSON.stringify(elementData), + elementType: 'Potentials', + }; + + const response = await $.http.post('/webservice.php', body); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-todo/fields.js b/packages/backend/src/apps/vtiger-crm/actions/create-todo/fields.js new file mode 100644 index 0000000..4cb8ee3 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-todo/fields.js @@ -0,0 +1,357 @@ +export const fields = [ + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + description: '', + variables: true, + }, + { + label: 'Assigned To', + key: 'assignedTo', + type: 'string', + required: false, + description: 'Default is the id of the account connected to Automatisch.', + variables: true, + }, + { + label: 'Start Date & Time', + key: 'startDateAndTime', + type: 'string', + required: false, + description: 'Format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Due Date', + key: 'dueDate', + type: 'string', + required: false, + description: 'Format: yyyy-mm-dd', + variables: true, + }, + { + label: 'Stage', + key: 'stage', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTodoOptions', + }, + { + name: 'parameters.stage', + value: 'taskstatus', + }, + ], + }, + }, + { + label: 'Contact', + key: 'contactId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listContacts', + }, + ], + }, + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: true, + description: '', + variables: true, + options: [ + { label: 'High', value: 'High' }, + { label: 'Medium', value: 'Medium' }, + { label: 'Low', value: 'Low' }, + ], + }, + { + label: 'Send Notification', + key: 'sendNotification', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ], + }, + { + label: 'Location', + key: 'location', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Record Currency', + key: 'recordCurrencyId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listRecordCurrencies', + }, + ], + }, + }, + { + label: 'Milestone', + key: 'milestone', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listMilestones', + }, + ], + }, + }, + { + label: 'Previous Task', + key: 'previousTask', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTasks', + }, + ], + }, + }, + { + label: 'Parent Task', + key: 'parentTask', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTasks', + }, + ], + }, + }, + { + label: 'Task Type', + key: 'taskType', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTodoOptions', + }, + { + name: 'parameters.taskType', + value: 'tasktype', + }, + ], + }, + }, + { + label: 'Skipped Reason', + key: 'skippedReason', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTodoOptions', + }, + { + name: 'parameters.skippedReason', + value: 'skipped_reason', + }, + ], + }, + }, + { + label: 'Estimate', + key: 'estimate', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Related Task', + key: 'relatedTask', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTasks', + }, + ], + }, + }, + { + label: 'Project', + key: 'projectId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listProjects', + }, + ], + }, + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Send Email Reminder Before', + key: 'sendEmailReminderBefore', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Description', + key: 'description', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'Is Billable', + key: 'isBillable', + type: 'dropdown', + required: false, + description: '', + variables: true, + options: [ + { label: 'True', value: '1' }, + { label: 'False', value: '-1' }, + ], + }, + { + label: 'Service', + key: 'service', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listServices', + }, + ], + }, + }, + { + label: 'Rate', + key: 'rate', + type: 'string', + required: false, + description: '', + variables: true, + }, + { + label: 'SLA', + key: 'slaId', + type: 'dropdown', + required: false, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSlaNames', + }, + ], + }, + }, +]; diff --git a/packages/backend/src/apps/vtiger-crm/actions/create-todo/index.js b/packages/backend/src/apps/vtiger-crm/actions/create-todo/index.js new file mode 100644 index 0000000..60419d3 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/create-todo/index.js @@ -0,0 +1,78 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create todo', + key: 'createTodo', + description: 'Create a new todo.', + arguments: fields, + + async run($) { + const { + name, + assignedTo, + startDateAndTime, + dueDate, + stage, + contactId, + priority, + sendNotification, + location, + recordCurrencyId, + milestone, + previousTask, + parentTask, + taskType, + skippedReason, + estimate, + relatedTask, + projectId, + organizationId, + sendEmailReminderBefore, + description, + isBillable, + service, + rate, + slaId, + } = $.step.parameters; + + const elementData = { + subject: name, + assigned_user_id: assignedTo || $.auth.data.userId, + date_start: startDateAndTime, + due_date: dueDate, + taskstatus: stage, + contact_id: contactId, + taskpriority: priority, + sendnotification: sendNotification, + location: location, + record_currency_id: recordCurrencyId, + milestone: milestone, + dependent_on: previousTask, + parent_task: parentTask, + tasktype: taskType, + skipped_reason: skippedReason, + estimate: estimate, + related_task: relatedTask, + related_project: projectId, + account_id: organizationId, + reminder_time: sendEmailReminderBefore, + description: description, + is_billable: isBillable, + billing_service: service, + rate: rate, + slaid: slaId, + }; + + const body = { + operation: 'create', + sessionName: $.auth.data.sessionName, + element: JSON.stringify(elementData), + elementType: 'Calendar', + }; + + const response = await $.http.post('/webservice.php', body); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/actions/index.js b/packages/backend/src/apps/vtiger-crm/actions/index.js new file mode 100644 index 0000000..a4cd130 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/actions/index.js @@ -0,0 +1,13 @@ +import createCase from './create-case/index.js'; +import createContact from './create-contact/index.js'; +import createLead from './create-lead/index.js'; +import createOpportunity from './create-opportunity/index.js'; +import createTodo from './create-todo/index.js'; + +export default [ + createCase, + createContact, + createLead, + createOpportunity, + createTodo, +]; diff --git a/packages/backend/src/apps/vtiger-crm/assets/favicon.svg b/packages/backend/src/apps/vtiger-crm/assets/favicon.svg new file mode 100644 index 0000000..0d95870 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/assets/favicon.svg @@ -0,0 +1,925 @@ + + + + diff --git a/packages/backend/src/apps/vtiger-crm/auth/index.js b/packages/backend/src/apps/vtiger-crm/auth/index.js new file mode 100644 index 0000000..03536e6 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/auth/index.js @@ -0,0 +1,44 @@ +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'username', + label: 'Username', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Email address of your Vtiger CRM account', + clickToCopy: false, + }, + { + key: 'accessKey', + label: 'Access Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Access Key of your Vtiger CRM account', + clickToCopy: false, + }, + { + key: 'domain', + label: 'Domain', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'For example: acmeco.od1 if your dashboard url is https://acmeco.od1.vtiger.com. (Unfortunately, we are not able to offer support for self-hosted instances at this moment.)', + clickToCopy: false, + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/vtiger-crm/auth/is-still-verified.js b/packages/backend/src/apps/vtiger-crm/auth/is-still-verified.js new file mode 100644 index 0000000..6663679 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/vtiger-crm/auth/verify-credentials.js b/packages/backend/src/apps/vtiger-crm/auth/verify-credentials.js new file mode 100644 index 0000000..eddc779 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/auth/verify-credentials.js @@ -0,0 +1,32 @@ +import crypto from 'crypto'; + +const verifyCredentials = async ($) => { + const params = { + operation: 'getchallenge', + username: $.auth.data.username, + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + const accessKey = crypto + .createHash('md5') + .update(data.result.token + $.auth.data.accessKey) + .digest('hex'); + + const body = { + operation: 'login', + username: $.auth.data.username, + accessKey, + }; + + const { data: result } = await $.http.post('/webservice.php', body); + + const response = await $.http.get('/restapi/v1/vtiger/default/me'); + + await $.auth.set({ + screenName: `${response.data.result?.first_name} ${response.data.result?.last_name}`, + sessionName: result.result.sessionName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/vtiger-crm/common/add-auth-header.js b/packages/backend/src/apps/vtiger-crm/common/add-auth-header.js new file mode 100644 index 0000000..52de16e --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/common/add-auth-header.js @@ -0,0 +1,15 @@ +const addAuthHeader = ($, requestConfig) => { + const { data } = $.auth; + + if (data?.username && data?.accessKey) { + requestConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + requestConfig.auth = { + username: data.username, + password: data.accessKey, + }; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/vtiger-crm/common/set-base-url.js b/packages/backend/src/apps/vtiger-crm/common/set-base-url.js new file mode 100644 index 0000000..a0dcffe --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/common/set-base-url.js @@ -0,0 +1,10 @@ +const setBaseUrl = ($, requestConfig) => { + const domain = $.auth.data.domain; + if (domain) { + requestConfig.baseURL = `https://${domain}.vtiger.com`; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/index.js new file mode 100644 index 0000000..c3ee6ac --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/index.js @@ -0,0 +1,39 @@ +import listAssets from './list-assets/index.js'; +import listCampaignSources from './list-campaign-sources/index.js'; +import listCaseOptions from './list-case-options/index.js'; +import listContactOptions from './list-contact-options/index.js'; +import listContacts from './list-contacts/index.js'; +import listGroups from './list-groups/index.js'; +import listLeadOptions from './list-lead-options/index.js'; +import listMilestones from './list-milestones/index.js'; +import listOpportunityOptions from './list-opportunity-options/index.js'; +import listOrganizations from './list-organizations/index.js'; +import listProducts from './list-products/index.js'; +import listProjects from './list-projects/index.js'; +import listRecordCurrencies from './list-record-currencies/index.js'; +import listServiceContracts from './list-service-contracts/index.js'; +import listServices from './list-services/index.js'; +import listSlaNames from './list-sla-names/index.js'; +import listTasks from './list-tasks/index.js'; +import listTodoOptions from './list-todo-options/index.js'; + +export default [ + listAssets, + listCampaignSources, + listCaseOptions, + listContactOptions, + listContacts, + listGroups, + listLeadOptions, + listMilestones, + listOpportunityOptions, + listOrganizations, + listProducts, + listProjects, + listRecordCurrencies, + listServiceContracts, + listServices, + listSlaNames, + listTasks, + listTodoOptions, +]; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-assets/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-assets/index.js new file mode 100644 index 0000000..3606a4f --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-assets/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List assets', + key: 'listAssets', + + async run($) { + const assets = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Assets ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const asset of data.result) { + assets.data.push({ + value: asset.id, + name: `${asset.assetname} (${asset.assetstatus})`, + }); + } + } + + return assets; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-campaign-sources/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-campaign-sources/index.js new file mode 100644 index 0000000..e255127 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-campaign-sources/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List campaign sources', + key: 'listCampaignSources', + + async run($) { + const campaignSources = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Campaigns ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result?.length) { + for (const campaignSource of data.result) { + campaignSources.data.push({ + value: campaignSource.id, + name: campaignSource.campaignname, + }); + } + } + + return campaignSources; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-case-options/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-case-options/index.js new file mode 100644 index 0000000..9cc0163 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-case-options/index.js @@ -0,0 +1,58 @@ +export default { + name: 'List case options', + key: 'listCaseOptions', + + async run($) { + const caseOptions = { + data: [], + }; + const { + status, + priority, + contactName, + productName, + channel, + category, + subCategory, + resolutionType, + serviceType, + serviceLocation, + } = $.step.parameters; + + const picklistFields = [ + status, + priority, + contactName, + productName, + channel, + category, + subCategory, + resolutionType, + serviceType, + serviceLocation, + ]; + + const params = { + operation: 'describe', + sessionName: $.auth.data.sessionName, + elementType: 'Cases', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result.fields?.length) { + for (const field of data.result.fields) { + if (picklistFields.includes(field.name)) { + field.type.picklistValues.map((item) => + caseOptions.data.push({ + value: item.value, + name: item.label, + }) + ); + } + } + } + + return caseOptions; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-contact-options/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-contact-options/index.js new file mode 100644 index 0000000..e27257d --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-contact-options/index.js @@ -0,0 +1,62 @@ +export default { + name: 'List contact options', + key: 'listContactOptions', + + async run($) { + const leadOptions = { + data: [], + }; + const { + leadSource, + lifecycleStage, + status, + title, + happinessRating, + emailOptin, + smsOptin, + language, + otherCountry, + mailingCountry, + mailingState, + otherState, + } = $.step.parameters; + + const picklistFields = [ + leadSource, + lifecycleStage, + status, + title, + happinessRating, + emailOptin, + smsOptin, + language, + otherCountry, + mailingCountry, + mailingState, + otherState, + ]; + + const params = { + operation: 'describe', + sessionName: $.auth.data.sessionName, + elementType: 'Contacts', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result.fields?.length) { + for (const field of data.result.fields) { + if (picklistFields.includes(field.name)) { + field.type.picklistValues.map((item) => + leadOptions.data.push({ + value: item.value, + name: item.label, + }) + ); + } + } + } + + return leadOptions; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-contacts/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-contacts/index.js new file mode 100644 index 0000000..c2d8c6f --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-contacts/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List contacts', + key: 'listContacts', + + async run($) { + const contacts = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Contacts ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result?.length) { + for (const contact of data.result) { + contacts.data.push({ + value: contact.id, + name: `${contact.firstname} ${contact.lastname}`, + }); + } + } + + return contacts; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-groups/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-groups/index.js new file mode 100644 index 0000000..51112b0 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-groups/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List groups', + key: 'listGroups', + + async run($) { + const groups = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Groups;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const group of data.result) { + groups.data.push({ + value: group.id, + name: group.groupname, + }); + } + } + + return groups; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-lead-options/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-lead-options/index.js new file mode 100644 index 0000000..bd7ab65 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-lead-options/index.js @@ -0,0 +1,56 @@ +export default { + name: 'List lead options', + key: 'listLeadOptions', + + async run($) { + const leadOptions = { + data: [], + }; + const { + designation, + industry, + leadSource, + leadStatus, + emailOptin, + smsOptin, + language, + country, + state, + } = $.step.parameters; + + const picklistFields = [ + designation, + industry, + leadSource, + leadStatus, + emailOptin, + smsOptin, + language, + country, + state, + ]; + + const params = { + operation: 'describe', + sessionName: $.auth.data.sessionName, + elementType: 'Leads', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result.fields?.length) { + for (const field of data.result.fields) { + if (picklistFields.includes(field.name)) { + field.type.picklistValues.map((item) => + leadOptions.data.push({ + value: item.value, + name: item.label, + }) + ); + } + } + } + + return leadOptions; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-milestones/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-milestones/index.js new file mode 100644 index 0000000..9bc7a50 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-milestones/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List milestones', + key: 'listMilestones', + + async run($) { + const milestones = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM ProjectMilestone ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const milestone of data.result) { + milestones.data.push({ + value: milestone.id, + name: milestone.projectmilestonename, + }); + } + } + + return milestones; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-opportunity-options/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-opportunity-options/index.js new file mode 100644 index 0000000..b86cd73 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-opportunity-options/index.js @@ -0,0 +1,38 @@ +export default { + name: 'List opportunity options', + key: 'listOpportunityOptions', + + async run($) { + const opportunityOptions = { + data: [], + }; + const leadSource = $.step.parameters.leadSource; + const lostReason = $.step.parameters.lostReason; + const type = $.step.parameters.type; + const salesStage = $.step.parameters.salesStage; + const picklistFields = [leadSource, lostReason, type, salesStage]; + + const params = { + operation: 'describe', + sessionName: $.auth.data.sessionName, + elementType: 'Potentials', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result.fields?.length) { + for (const field of data.result.fields) { + if (picklistFields.includes(field.name)) { + field.type.picklistValues.map((item) => + opportunityOptions.data.push({ + value: item.value, + name: item.label, + }) + ); + } + } + } + + return opportunityOptions; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-organizations/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-organizations/index.js new file mode 100644 index 0000000..77b6030 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-organizations/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List organizations', + key: 'listOrganizations', + + async run($) { + const organizations = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Accounts ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result?.length) { + for (const organization of data.result) { + organizations.data.push({ + value: organization.id, + name: organization.accountname, + }); + } + } + + return organizations; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-products/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-products/index.js new file mode 100644 index 0000000..f4be10c --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-products/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List products', + key: 'listProducts', + + async run($) { + const products = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Products ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const product of data.result) { + products.data.push({ + value: product.id, + name: product.productname, + }); + } + } + + return products; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-projects/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-projects/index.js new file mode 100644 index 0000000..ec12956 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-projects/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List projects', + key: 'listProjects', + + async run($) { + const projects = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Project ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const project of data.result) { + projects.data.push({ + value: project.id, + name: project.projectname, + }); + } + } + + return projects; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-record-currencies/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-record-currencies/index.js new file mode 100644 index 0000000..b416a65 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-record-currencies/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List record currencies', + key: 'listRecordCurrencies', + + async run($) { + const recordCurrencies = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Currency;', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result?.length) { + for (const recordCurrency of data.result) { + recordCurrencies.data.push({ + value: recordCurrency.id, + name: recordCurrency.currency_code, + }); + } + } + + return recordCurrencies; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-service-contracts/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-service-contracts/index.js new file mode 100644 index 0000000..d67e5d0 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-service-contracts/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List service contracts', + key: 'listServiceContracts', + + async run($) { + const serviceContracts = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM ServiceContracts ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const serviceContract of data.result) { + serviceContracts.data.push({ + value: serviceContract.id, + name: serviceContract.subject, + }); + } + } + + return serviceContracts; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-services/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-services/index.js new file mode 100644 index 0000000..c08c4ae --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-services/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List services', + key: 'listServices', + + async run($) { + const services = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Services ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const service of data.result) { + services.data.push({ + value: service.id, + name: service.servicename, + }); + } + } + + return services; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-sla-names/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-sla-names/index.js new file mode 100644 index 0000000..5026c82 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-sla-names/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List sla names', + key: 'listSlaNames', + + async run($) { + const slaNames = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM SLA ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get(`/webservice.php`, { params }); + + if (data.result?.length) { + for (const slaName of data.result) { + slaNames.data.push({ + value: slaName.id, + name: slaName.policy_name, + }); + } + } + + return slaNames; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-tasks/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-tasks/index.js new file mode 100644 index 0000000..87c174f --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-tasks/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List tasks', + key: 'listTasks', + + async run($) { + const tasks = { + data: [], + }; + + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: 'SELECT * FROM Calendar ORDER BY createdtime DESC;', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result?.length) { + for (const task of data.result) { + tasks.data.push({ + value: task.id, + name: task.subject, + }); + } + } + + return tasks; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/dynamic-data/list-todo-options/index.js b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-todo-options/index.js new file mode 100644 index 0000000..ca0d1f4 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/dynamic-data/list-todo-options/index.js @@ -0,0 +1,37 @@ +export default { + name: 'List todo options', + key: 'listTodoOptions', + + async run($) { + const todoOptions = { + data: [], + }; + const stage = $.step.parameters.stage; + const taskType = $.step.parameters.taskType; + const skippedReason = $.step.parameters.skippedReason; + const picklistFields = [stage, taskType, skippedReason]; + + const params = { + operation: 'describe', + sessionName: $.auth.data.sessionName, + elementType: 'Calendar', + }; + + const { data } = await $.http.get('/webservice.php', { params }); + + if (data.result.fields?.length) { + for (const field of data.result.fields) { + if (picklistFields.includes(field.name)) { + field.type.picklistValues.map((item) => + todoOptions.data.push({ + value: item.value, + name: item.label, + }) + ); + } + } + } + + return todoOptions; + }, +}; diff --git a/packages/backend/src/apps/vtiger-crm/index.js b/packages/backend/src/apps/vtiger-crm/index.js new file mode 100644 index 0000000..b5ab344 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/index.js @@ -0,0 +1,23 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Vtiger CRM', + key: 'vtiger-crm', + iconUrl: '{BASE_URL}/apps/vtiger-crm/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/vtiger-crm/connection', + supportsConnections: true, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#39a86d', + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/vtiger-crm/triggers/index.js b/packages/backend/src/apps/vtiger-crm/triggers/index.js new file mode 100644 index 0000000..faf1bff --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/index.js @@ -0,0 +1,15 @@ +import newCases from './new-cases/index.js'; +import newContacts from './new-contacts/index.js'; +import newInvoices from './new-invoices/index.js'; +import newLeads from './new-leads/index.js'; +import newOpportunities from './new-opportunities/index.js'; +import newTodos from './new-todos/index.js'; + +export default [ + newCases, + newContacts, + newInvoices, + newLeads, + newOpportunities, + newTodos, +]; diff --git a/packages/backend/src/apps/vtiger-crm/triggers/new-cases/index.js b/packages/backend/src/apps/vtiger-crm/triggers/new-cases/index.js new file mode 100644 index 0000000..4091515 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/new-cases/index.js @@ -0,0 +1,40 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New cases', + key: 'newCases', + pollInterval: 15, + description: 'Triggers when a new case is created.', + + async run($) { + let offset = 0; + const limit = 100; + let hasMore = true; + + do { + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: `SELECT * FROM Cases ORDER BY createdtime DESC LIMIT ${offset}, ${limit};`, + }; + + const { data } = await $.http.get('/webservice.php', { + params, + }); + offset = limit + offset; + + if (!data.result?.length || data.result.length < limit) { + hasMore = false; + } + + for (const item of data.result) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.id, + }, + }); + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/triggers/new-contacts/index.js b/packages/backend/src/apps/vtiger-crm/triggers/new-contacts/index.js new file mode 100644 index 0000000..0a8da87 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/new-contacts/index.js @@ -0,0 +1,40 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New contacts', + key: 'newContacts', + pollInterval: 15, + description: 'Triggers when a new contact is created.', + + async run($) { + let offset = 0; + const limit = 100; + let hasMore = true; + + do { + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: `SELECT * FROM Contacts ORDER BY createdtime DESC LIMIT ${offset}, ${limit};`, + }; + + const { data } = await $.http.get('/webservice.php', { + params, + }); + offset = limit + offset; + + if (!data.result?.length || data.result.length < limit) { + hasMore = false; + } + + for (const item of data.result) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.id, + }, + }); + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/triggers/new-invoices/index.js b/packages/backend/src/apps/vtiger-crm/triggers/new-invoices/index.js new file mode 100644 index 0000000..ac17d6f --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/new-invoices/index.js @@ -0,0 +1,40 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New invoices', + key: 'newInvoices', + pollInterval: 15, + description: 'Triggers when a new invoice is created.', + + async run($) { + let offset = 0; + const limit = 100; + let hasMore = true; + + do { + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: `SELECT * FROM Invoice ORDER BY createdtime DESC LIMIT ${offset}, ${limit};`, + }; + + const { data } = await $.http.get('/webservice.php', { + params, + }); + offset = limit + offset; + + if (!data.result?.length || data.result.length < limit) { + hasMore = false; + } + + for (const item of data.result) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.id, + }, + }); + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/triggers/new-leads/index.js b/packages/backend/src/apps/vtiger-crm/triggers/new-leads/index.js new file mode 100644 index 0000000..03999ba --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/new-leads/index.js @@ -0,0 +1,40 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New leads', + key: 'newLeads', + pollInterval: 15, + description: 'Triggers when a new lead is created.', + + async run($) { + let offset = 0; + const limit = 100; + let hasMore = true; + + do { + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: `SELECT * FROM Leads ORDER BY createdtime DESC LIMIT ${offset}, ${limit};`, + }; + + const { data } = await $.http.get('/webservice.php', { + params, + }); + offset = limit + offset; + + if (!data.result?.length || data.result.length < limit) { + hasMore = false; + } + + for (const item of data.result) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.id, + }, + }); + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/triggers/new-opportunities/index.js b/packages/backend/src/apps/vtiger-crm/triggers/new-opportunities/index.js new file mode 100644 index 0000000..db3cd15 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/new-opportunities/index.js @@ -0,0 +1,40 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New opportunities', + key: 'newOpportunities', + pollInterval: 15, + description: 'Triggers when a new opportunity is created.', + + async run($) { + let offset = 0; + const limit = 100; + let hasMore = true; + + do { + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: `SELECT * FROM Potentials ORDER BY createdtime DESC LIMIT ${offset}, ${limit};`, + }; + + const { data } = await $.http.get('/webservice.php', { + params, + }); + offset = limit + offset; + + if (!data.result?.length || data.result.length < limit) { + hasMore = false; + } + + for (const item of data.result) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.id, + }, + }); + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/vtiger-crm/triggers/new-todos/index.js b/packages/backend/src/apps/vtiger-crm/triggers/new-todos/index.js new file mode 100644 index 0000000..4bf7229 --- /dev/null +++ b/packages/backend/src/apps/vtiger-crm/triggers/new-todos/index.js @@ -0,0 +1,40 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New todos', + key: 'newTodos', + pollInterval: 15, + description: 'Triggers when a new todo is created.', + + async run($) { + let offset = 0; + const limit = 100; + let hasMore = true; + + do { + const params = { + operation: 'query', + sessionName: $.auth.data.sessionName, + query: `SELECT * FROM Calendar ORDER BY createdtime DESC LIMIT ${offset}, ${limit};`, + }; + + const { data } = await $.http.get('/webservice.php', { + params, + }); + offset = limit + offset; + + if (!data.result?.length || data.result.length < limit) { + hasMore = false; + } + + for (const item of data.result) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.id, + }, + }); + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/webhook/actions/index.js b/packages/backend/src/apps/webhook/actions/index.js new file mode 100644 index 0000000..a7c4897 --- /dev/null +++ b/packages/backend/src/apps/webhook/actions/index.js @@ -0,0 +1,3 @@ +import respondWith from './respond-with/index.js'; + +export default [respondWith]; diff --git a/packages/backend/src/apps/webhook/actions/respond-with/index.js b/packages/backend/src/apps/webhook/actions/respond-with/index.js new file mode 100644 index 0000000..b33d10f --- /dev/null +++ b/packages/backend/src/apps/webhook/actions/respond-with/index.js @@ -0,0 +1,69 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Respond with', + key: 'respondWith', + description: 'Respond with defined JSON body.', + arguments: [ + { + label: 'Status code', + key: 'statusCode', + type: 'string', + required: true, + variables: true, + value: '200', + }, + { + label: 'Headers', + key: 'headers', + type: 'dynamic', + required: false, + description: 'Add or remove headers as needed', + fields: [ + { + label: 'Key', + key: 'key', + type: 'string', + required: true, + description: 'Header key', + variables: true, + }, + { + label: 'Value', + key: 'value', + type: 'string', + required: true, + description: 'Header value', + variables: true, + }, + ], + }, + { + label: 'Body', + key: 'body', + type: 'string', + required: true, + description: 'The content of the response body.', + variables: true, + }, + ], + + async run($) { + const statusCode = parseInt($.step.parameters.statusCode, 10); + const body = $.step.parameters.body; + const headers = $.step.parameters.headers.reduce((result, entry) => { + return { + ...result, + [entry.key]: entry.value, + }; + }, {}); + + $.setActionItem({ + raw: { + headers, + body, + statusCode, + }, + }); + }, +}); diff --git a/packages/backend/src/apps/webhook/assets/favicon.svg b/packages/backend/src/apps/webhook/assets/favicon.svg new file mode 100644 index 0000000..140ebd6 --- /dev/null +++ b/packages/backend/src/apps/webhook/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/webhook/index.js b/packages/backend/src/apps/webhook/index.js new file mode 100644 index 0000000..ed5ca56 --- /dev/null +++ b/packages/backend/src/apps/webhook/index.js @@ -0,0 +1,16 @@ +import defineApp from '../../helpers/define-app.js'; +import actions from './actions/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Webhook', + key: 'webhook', + iconUrl: '{BASE_URL}/apps/webhook/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/webhook/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '#0059F7', + actions, + triggers, +}); diff --git a/packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.js b/packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.js new file mode 100644 index 0000000..0494d22 --- /dev/null +++ b/packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.js @@ -0,0 +1,52 @@ +import Crypto from 'crypto'; +import isEmpty from 'lodash/isEmpty.js'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Catch raw webhook', + key: 'catchRawWebhook', + type: 'webhook', + showWebhookUrl: true, + description: + 'Triggers (immediately if configured) when the webhook receives a request.', + arguments: [ + { + label: 'Wait until flow is done', + key: 'workSynchronously', + type: 'dropdown', + required: true, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + ], + + async run($) { + const dataItem = { + raw: { + headers: $.request.headers, + body: $.request.body, + query: $.request.query, + }, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const lastExecutionStep = await $.getLastExecutionStep(); + + if (!isEmpty(lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: lastExecutionStep.dataOut, + meta: { + internalId: '', + }, + }); + } + }, +}); diff --git a/packages/backend/src/apps/webhook/triggers/index.js b/packages/backend/src/apps/webhook/triggers/index.js new file mode 100644 index 0000000..166d427 --- /dev/null +++ b/packages/backend/src/apps/webhook/triggers/index.js @@ -0,0 +1,3 @@ +import catchRawWebhook from './catch-raw-webhook/index.js'; + +export default [catchRawWebhook]; diff --git a/packages/backend/src/apps/wordpress/assets/favicon.svg b/packages/backend/src/apps/wordpress/assets/favicon.svg new file mode 100644 index 0000000..39be6e1 --- /dev/null +++ b/packages/backend/src/apps/wordpress/assets/favicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/wordpress/auth/generate-auth-url.js b/packages/backend/src/apps/wordpress/auth/generate-auth-url.js new file mode 100644 index 0000000..d7eeb96 --- /dev/null +++ b/packages/backend/src/apps/wordpress/auth/generate-auth-url.js @@ -0,0 +1,26 @@ +import { URL, URLSearchParams } from 'node:url'; + +import appConfig from '../../../config/app.js'; +import getInstanceUrl from '../common/get-instance-url.js'; + +export default async function generateAuthUrl($) { + const successUrl = new URL( + '/app/wordpress/connections/add', + appConfig.webAppUrl + ).toString(); + const baseUrl = getInstanceUrl($); + + const searchParams = new URLSearchParams({ + app_name: 'automatisch', + success_url: successUrl, + }); + + const url = new URL( + `/wp-admin/authorize-application.php?${searchParams}`, + baseUrl + ).toString(); + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/wordpress/auth/index.js b/packages/backend/src/apps/wordpress/auth/index.js new file mode 100644 index 0000000..73aadf0 --- /dev/null +++ b/packages/backend/src/apps/wordpress/auth/index.js @@ -0,0 +1,24 @@ +import generateAuthUrl from './generate-auth-url.js'; +import isStillVerified from './is-still-verified.js'; +import verifyCredentials from './verify-credentials.js'; + +export default { + fields: [ + { + key: 'instanceUrl', + label: 'WordPress instance URL', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Your WordPress instance URL.', + docUrl: 'https://automatisch.io/docs/wordpress#instance-url', + clickToCopy: true, + }, + ], + + generateAuthUrl, + isStillVerified, + verifyCredentials, +}; diff --git a/packages/backend/src/apps/wordpress/auth/is-still-verified.js b/packages/backend/src/apps/wordpress/auth/is-still-verified.js new file mode 100644 index 0000000..d77bac2 --- /dev/null +++ b/packages/backend/src/apps/wordpress/auth/is-still-verified.js @@ -0,0 +1,7 @@ +const isStillVerified = async ($) => { + await $.http.get('?rest_route=/wp/v2/settings'); + + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/wordpress/auth/verify-credentials.js b/packages/backend/src/apps/wordpress/auth/verify-credentials.js new file mode 100644 index 0000000..b1cf86b --- /dev/null +++ b/packages/backend/src/apps/wordpress/auth/verify-credentials.js @@ -0,0 +1,22 @@ +const verifyCredentials = async ($) => { + const instanceUrl = $.auth.data.instanceUrl; + const password = $.auth.data.password; + const siteUrl = $.auth.data.site_url; + const url = $.auth.data.url; + const userLogin = $.auth.data.user_login; + + if (!password) { + throw new Error('Failed while authorizing!'); + } + + await $.auth.set({ + screenName: `${userLogin} @ ${siteUrl}`, + instanceUrl, + password, + siteUrl, + url, + userLogin, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/wordpress/common/add-auth-header.js b/packages/backend/src/apps/wordpress/common/add-auth-header.js new file mode 100644 index 0000000..e3a668c --- /dev/null +++ b/packages/backend/src/apps/wordpress/common/add-auth-header.js @@ -0,0 +1,15 @@ +const addAuthHeader = ($, requestConfig) => { + const userLogin = $.auth.data.userLogin; + const password = $.auth.data.password; + + if (userLogin && password) { + requestConfig.auth = { + username: userLogin, + password, + }; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/wordpress/common/get-instance-url.js b/packages/backend/src/apps/wordpress/common/get-instance-url.js new file mode 100644 index 0000000..0d7125a --- /dev/null +++ b/packages/backend/src/apps/wordpress/common/get-instance-url.js @@ -0,0 +1,5 @@ +const getInstanceUrl = ($) => { + return $.auth.data.instanceUrl; +}; + +export default getInstanceUrl; diff --git a/packages/backend/src/apps/wordpress/common/set-base-url.js b/packages/backend/src/apps/wordpress/common/set-base-url.js new file mode 100644 index 0000000..def1330 --- /dev/null +++ b/packages/backend/src/apps/wordpress/common/set-base-url.js @@ -0,0 +1,10 @@ +const setBaseUrl = ($, requestConfig) => { + const instanceUrl = $.auth.data.instanceUrl; + if (instanceUrl) { + requestConfig.baseURL = instanceUrl; + } + + return requestConfig; +}; + +export default setBaseUrl; diff --git a/packages/backend/src/apps/wordpress/dynamic-data/index.js b/packages/backend/src/apps/wordpress/dynamic-data/index.js new file mode 100644 index 0000000..ce289b3 --- /dev/null +++ b/packages/backend/src/apps/wordpress/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listStatuses from './list-statuses/index.js'; + +export default [listStatuses]; diff --git a/packages/backend/src/apps/wordpress/dynamic-data/list-statuses/index.js b/packages/backend/src/apps/wordpress/dynamic-data/list-statuses/index.js new file mode 100644 index 0000000..f457228 --- /dev/null +++ b/packages/backend/src/apps/wordpress/dynamic-data/list-statuses/index.js @@ -0,0 +1,27 @@ +export default { + name: 'List statuses', + key: 'listStatuses', + + async run($) { + const statuses = { + data: [], + }; + + const { data } = await $.http.get('?rest_route=/wp/v2/statuses'); + + if (!data) return statuses; + + const values = Object.values(data); + + if (!values?.length) return statuses; + + for (const status of values) { + statuses.data.push({ + value: status.slug, + name: status.name, + }); + } + + return statuses; + }, +}; diff --git a/packages/backend/src/apps/wordpress/index.js b/packages/backend/src/apps/wordpress/index.js new file mode 100644 index 0000000..4736825 --- /dev/null +++ b/packages/backend/src/apps/wordpress/index.js @@ -0,0 +1,21 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import setBaseUrl from './common/set-base-url.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'WordPress', + key: 'wordpress', + iconUrl: '{BASE_URL}/apps/wordpress/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/wordpress/connection', + supportsConnections: true, + baseUrl: 'https://wordpress.com', + apiBaseUrl: '', + primaryColor: '#464342', + beforeRequest: [setBaseUrl, addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/wordpress/triggers/index.js b/packages/backend/src/apps/wordpress/triggers/index.js new file mode 100644 index 0000000..619c8ca --- /dev/null +++ b/packages/backend/src/apps/wordpress/triggers/index.js @@ -0,0 +1,5 @@ +import newComment from './new-comment/index.js'; +import newPage from './new-page/index.js'; +import newPost from './new-post/index.js'; + +export default [newComment, newPage, newPost]; diff --git a/packages/backend/src/apps/wordpress/triggers/new-comment/index.js b/packages/backend/src/apps/wordpress/triggers/new-comment/index.js new file mode 100644 index 0000000..1f597fd --- /dev/null +++ b/packages/backend/src/apps/wordpress/triggers/new-comment/index.js @@ -0,0 +1,59 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New comment', + key: 'newComment', + description: 'Triggers when a new comment is created.', + pollInterval: 15, + arguments: [ + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: true, + variables: true, + options: [ + { label: 'Approve', value: 'approve' }, + { label: 'Unapprove', value: 'hold' }, + { label: 'Spam', value: 'spam' }, + { label: 'Trash', value: 'trash' }, + ], + }, + ], + + async run($) { + const params = { + per_page: 100, + page: 1, + order: 'desc', + orderby: 'date', + status: $.step.parameters.status || '', + }; + + let totalPages = 1; + do { + const { data, headers } = await $.http.get( + '?rest_route=/wp/v2/comments', + { + params, + } + ); + + params.page = params.page + 1; + totalPages = Number(headers['x-wp-totalpages']); + + if (data.length) { + for (const page of data) { + const dataItem = { + raw: page, + meta: { + internalId: page.id.toString(), + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (params.page <= totalPages); + }, +}); diff --git a/packages/backend/src/apps/wordpress/triggers/new-page/index.js b/packages/backend/src/apps/wordpress/triggers/new-page/index.js new file mode 100644 index 0000000..4878680 --- /dev/null +++ b/packages/backend/src/apps/wordpress/triggers/new-page/index.js @@ -0,0 +1,60 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New page', + key: 'newPage', + pollInterval: 15, + description: 'Triggers when a new page is created.', + arguments: [ + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listStatuses', + }, + ], + }, + }, + ], + + async run($) { + const params = { + per_page: 100, + page: 1, + order: 'desc', + orderby: 'date', + status: $.step.parameters.status || '', + }; + + let totalPages = 1; + do { + const { data, headers } = await $.http.get('?rest_route=/wp/v2/pages', { + params, + }); + + params.page = params.page + 1; + totalPages = Number(headers['x-wp-totalpages']); + + if (data.length) { + for (const page of data) { + const dataItem = { + raw: page, + meta: { + internalId: page.id.toString(), + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (params.page <= totalPages); + }, +}); diff --git a/packages/backend/src/apps/wordpress/triggers/new-post/index.js b/packages/backend/src/apps/wordpress/triggers/new-post/index.js new file mode 100644 index 0000000..e1814bc --- /dev/null +++ b/packages/backend/src/apps/wordpress/triggers/new-post/index.js @@ -0,0 +1,60 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New post', + key: 'newPost', + pollInterval: 15, + description: 'Triggers when a new post is created.', + arguments: [ + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listStatuses', + }, + ], + }, + }, + ], + + async run($) { + const params = { + per_page: 100, + page: 1, + order: 'desc', + orderby: 'date', + status: $.step.parameters.status || '', + }; + + let totalPages = 1; + do { + const { data, headers } = await $.http.get('?rest_route=/wp/v2/posts', { + params, + }); + + params.page = params.page + 1; + totalPages = Number(headers['x-wp-totalpages']); + + if (data.length) { + for (const post of data) { + const dataItem = { + raw: post, + meta: { + internalId: post.id.toString(), + }, + }; + + $.pushTriggerItem(dataItem); + } + } + } while (params.page <= totalPages); + }, +}); diff --git a/packages/backend/src/apps/xero/assets/favicon.svg b/packages/backend/src/apps/xero/assets/favicon.svg new file mode 100644 index 0000000..e1cd725 --- /dev/null +++ b/packages/backend/src/apps/xero/assets/favicon.svg @@ -0,0 +1 @@ +Xero homepageBeautiful business \ No newline at end of file diff --git a/packages/backend/src/apps/xero/auth/generate-auth-url.js b/packages/backend/src/apps/xero/auth/generate-auth-url.js new file mode 100644 index 0000000..e535dce --- /dev/null +++ b/packages/backend/src/apps/xero/auth/generate-auth-url.js @@ -0,0 +1,21 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + response_type: 'code', + client_id: $.auth.data.clientId, + scope: authScope.join(' '), + redirect_uri: redirectUri, + }); + + const url = `https://login.xero.com/identity/connect/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/xero/auth/index.js b/packages/backend/src/apps/xero/auth/index.js new file mode 100644 index 0000000..d84c9a7 --- /dev/null +++ b/packages/backend/src/apps/xero/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/xero/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Xero, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/xero/auth/is-still-verified.js b/packages/backend/src/apps/xero/auth/is-still-verified.js new file mode 100644 index 0000000..45b89a6 --- /dev/null +++ b/packages/backend/src/apps/xero/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.tenantName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/xero/auth/refresh-token.js b/packages/backend/src/apps/xero/auth/refresh-token.js new file mode 100644 index 0000000..75d1c21 --- /dev/null +++ b/packages/backend/src/apps/xero/auth/refresh-token.js @@ -0,0 +1,39 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://identity.xero.com/connect/token', + params.toString(), + { + headers, + additionalProperties: { + skipAddingAuthHeader: true, + }, + } + ); + + await $.auth.set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + idToken: data.id_token, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/xero/auth/verify-credentials.js b/packages/backend/src/apps/xero/auth/verify-credentials.js new file mode 100644 index 0000000..61170d1 --- /dev/null +++ b/packages/backend/src/apps/xero/auth/verify-credentials.js @@ -0,0 +1,52 @@ +import getCurrentUser from '../common/get-current-user.js'; +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const headers = { + Authorization: `Basic ${Buffer.from( + $.auth.data.clientId + ':' + $.auth.data.clientSecret + ).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: $.auth.data.code, + redirect_uri: redirectUri, + }); + + const { data } = await $.http.post( + 'https://identity.xero.com/connect/token', + params.toString(), + { + headers, + } + ); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + idToken: data.id_token, + }); + + const currentUser = await getCurrentUser($); + + const screenName = [currentUser.tenantName, currentUser.tenantType] + .filter(Boolean) + .join(' @ '); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + tenantId: currentUser.tenantId, + screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/xero/common/add-auth-header.js b/packages/backend/src/apps/xero/common/add-auth-header.js new file mode 100644 index 0000000..045e4d2 --- /dev/null +++ b/packages/backend/src/apps/xero/common/add-auth-header.js @@ -0,0 +1,16 @@ +const addAuthHeader = ($, requestConfig) => { + if (requestConfig.additionalProperties?.skipAddingAuthHeader) + return requestConfig; + + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + if ($.auth.data?.tenantId) { + requestConfig.headers['Xero-tenant-id'] = $.auth.data.tenantId; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/xero/common/auth-scope.js b/packages/backend/src/apps/xero/common/auth-scope.js new file mode 100644 index 0000000..6a4b548 --- /dev/null +++ b/packages/backend/src/apps/xero/common/auth-scope.js @@ -0,0 +1,10 @@ +const authScope = [ + 'offline_access', + 'openid', + 'profile', + 'email', + 'accounting.transactions', + 'accounting.settings', +]; + +export default authScope; diff --git a/packages/backend/src/apps/xero/common/get-current-user.js b/packages/backend/src/apps/xero/common/get-current-user.js new file mode 100644 index 0000000..0bc6916 --- /dev/null +++ b/packages/backend/src/apps/xero/common/get-current-user.js @@ -0,0 +1,6 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get('/connections'); + return currentUser[0]; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/xero/dynamic-data/index.js b/packages/backend/src/apps/xero/dynamic-data/index.js new file mode 100644 index 0000000..de48bc3 --- /dev/null +++ b/packages/backend/src/apps/xero/dynamic-data/index.js @@ -0,0 +1,3 @@ +import listOrganizations from './list-organizations/index.js'; + +export default [listOrganizations]; diff --git a/packages/backend/src/apps/xero/dynamic-data/list-organizations/index.js b/packages/backend/src/apps/xero/dynamic-data/list-organizations/index.js new file mode 100644 index 0000000..ea16b5a --- /dev/null +++ b/packages/backend/src/apps/xero/dynamic-data/list-organizations/index.js @@ -0,0 +1,23 @@ +export default { + name: 'List organizations', + key: 'listOrganizations', + + async run($) { + const organizations = { + data: [], + }; + + const { data } = await $.http.get('/api.xro/2.0/Organisation'); + + if (data.Organisations?.length) { + for (const organization of data.Organisations) { + organizations.data.push({ + value: organization.OrganisationID, + name: organization.Name, + }); + } + } + + return organizations; + }, +}; diff --git a/packages/backend/src/apps/xero/index.js b/packages/backend/src/apps/xero/index.js new file mode 100644 index 0000000..b0dd2ee --- /dev/null +++ b/packages/backend/src/apps/xero/index.js @@ -0,0 +1,20 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Xero', + key: 'xero', + baseUrl: 'https://go.xero.com', + apiBaseUrl: 'https://api.xero.com', + iconUrl: '{BASE_URL}/apps/xero/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/xero/connection', + primaryColor: '#13B5EA', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + dynamicData, +}); diff --git a/packages/backend/src/apps/xero/triggers/index.js b/packages/backend/src/apps/xero/triggers/index.js new file mode 100644 index 0000000..e4b774b --- /dev/null +++ b/packages/backend/src/apps/xero/triggers/index.js @@ -0,0 +1,4 @@ +import newBankTransactions from './new-bank-transactions/index.js'; +import newPayments from './new-payments/index.js'; + +export default [newBankTransactions, newPayments]; diff --git a/packages/backend/src/apps/xero/triggers/new-bank-transactions/index.js b/packages/backend/src/apps/xero/triggers/new-bank-transactions/index.js new file mode 100644 index 0000000..b1f012e --- /dev/null +++ b/packages/backend/src/apps/xero/triggers/new-bank-transactions/index.js @@ -0,0 +1,60 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New bank transactions', + key: 'newBankTransactions', + pollInterval: 15, + description: 'Triggers when a new bank transaction occurs.', + arguments: [ + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + ], + + async run($) { + const params = { + page: 1, + order: 'Date DESC', + }; + + let nextPage = false; + do { + const { data } = await $.http.get('/api.xro/2.0/BankTransactions', { + params, + }); + params.page = params.page + 1; + + if (data.BankTransactions?.length) { + for (const bankTransaction of data.BankTransactions) { + $.pushTriggerItem({ + raw: bankTransaction, + meta: { + internalId: bankTransaction.BankTransactionID, + }, + }); + } + } + + if (data.BankTransactions?.length === 100) { + nextPage = true; + } else { + nextPage = false; + } + } while (nextPage); + }, +}); diff --git a/packages/backend/src/apps/xero/triggers/new-payments/index.js b/packages/backend/src/apps/xero/triggers/new-payments/index.js new file mode 100644 index 0000000..fbd8bd7 --- /dev/null +++ b/packages/backend/src/apps/xero/triggers/new-payments/index.js @@ -0,0 +1,103 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New payments', + key: 'newPayments', + pollInterval: 15, + description: 'Triggers when a new payment is received.', + arguments: [ + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: true, + description: '', + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'Payment Type', + key: 'paymentType', + type: 'dropdown', + required: false, + description: '', + variables: true, + value: '', + options: [ + { label: 'Accounts Receivable', value: 'ACCRECPAYMENT' }, + { label: 'Accounts Payable', value: 'ACCPAYPAYMENT' }, + { + label: 'Accounts Receivable Credit (Refund)', + value: 'ARCREDITPAYMENT', + }, + { + label: 'Accounts Payable Credit (Refund)', + value: 'APCREDITPAYMENT', + }, + { + label: 'Accounts Receivable Overpayment (Refund)', + value: 'AROVERPAYMENTPAYMENT', + }, + { + label: 'Accounts Receivable Prepayment (Refund)', + value: 'ARPREPAYMENTPAYMENT', + }, + { + label: 'Accounts Payable Prepayment (Refund)', + value: 'APPREPAYMENTPAYMENT', + }, + { + label: 'Accounts Payable Overpayment (Refund)', + value: 'APOVERPAYMENTPAYMENT', + }, + ], + }, + ], + + async run($) { + const paymentType = $.step.parameters.paymentType; + + const params = { + page: 1, + order: 'Date DESC', + }; + + if (paymentType) { + params.where = `PaymentType="${paymentType}"`; + } + + let nextPage = false; + do { + const { data } = await $.http.get('/api.xro/2.0/Payments', { + params, + }); + params.page = params.page + 1; + + if (data.Payments?.length) { + for (const payment of data.Payments) { + $.pushTriggerItem({ + raw: payment, + meta: { + internalId: payment.PaymentID, + }, + }); + } + } + + if (data.Payments?.length === 100) { + nextPage = true; + } else { + nextPage = false; + } + } while (nextPage); + }, +}); diff --git a/packages/backend/src/apps/you-need-a-budget/assets/favicon.svg b/packages/backend/src/apps/you-need-a-budget/assets/favicon.svg new file mode 100644 index 0000000..c83333c --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/assets/favicon.svg @@ -0,0 +1,25 @@ + + + + My Budget + + \ No newline at end of file diff --git a/packages/backend/src/apps/you-need-a-budget/auth/generate-auth-url.js b/packages/backend/src/apps/you-need-a-budget/auth/generate-auth-url.js new file mode 100644 index 0000000..12d647e --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/auth/generate-auth-url.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'url'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const state = Math.random().toString(); + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + }); + + const url = `https://app.ynab.com/oauth/authorize?${searchParams.toString()}`; + + await $.auth.set({ + url, + originalState: state, + }); +} diff --git a/packages/backend/src/apps/you-need-a-budget/auth/index.js b/packages/backend/src/apps/you-need-a-budget/auth/index.js new file mode 100644 index 0000000..32eef53 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/auth/index.js @@ -0,0 +1,60 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/you-need-a-budget/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in You Need A Budget, enter the URL above.', + clickToCopy: true, + }, + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/you-need-a-budget/auth/is-still-verified.js b/packages/backend/src/apps/you-need-a-budget/auth/is-still-verified.js new file mode 100644 index 0000000..6d792b1 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/you-need-a-budget/auth/refresh-token.js b/packages/backend/src/apps/you-need-a-budget/auth/refresh-token.js new file mode 100644 index 0000000..9c7830e --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/auth/refresh-token.js @@ -0,0 +1,25 @@ +import { URLSearchParams } from 'node:url'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://app.ynab.com/oauth/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + scope: data.scope, + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/you-need-a-budget/auth/verify-credentials.js b/packages/backend/src/apps/you-need-a-budget/auth/verify-credentials.js new file mode 100644 index 0000000..19d88fa --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/auth/verify-credentials.js @@ -0,0 +1,33 @@ +const verifyCredentials = async ($) => { + if ($.auth.data.originalState !== $.auth.data.state) { + throw new Error(`The 'state' parameter does not match.`); + } + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post('https://app.ynab.com/oauth/token', { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + code: $.auth.data.code, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: data.scope, + createdAt: data.created_at, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + screenName: $.auth.data.screenName, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/you-need-a-budget/common/add-auth-header.js b/packages/backend/src/apps/you-need-a-budget/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/you-need-a-budget/common/get-current-user.js b/packages/backend/src/apps/you-need-a-budget/common/get-current-user.js new file mode 100644 index 0000000..65dea82 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/common/get-current-user.js @@ -0,0 +1,6 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get('/user'); + return currentUser.data.user.id; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/you-need-a-budget/index.js b/packages/backend/src/apps/you-need-a-budget/index.js new file mode 100644 index 0000000..d9718e9 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'You Need A Budget', + key: 'you-need-a-budget', + baseUrl: 'https://app.ynab.com', + apiBaseUrl: 'https://api.ynab.com/v1', + iconUrl: '{BASE_URL}/apps/you-need-a-budget/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/you-need-a-budget/connection', + primaryColor: '#19223C', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/you-need-a-budget/triggers/category-overspent/index.js b/packages/backend/src/apps/you-need-a-budget/triggers/category-overspent/index.js new file mode 100644 index 0000000..9e3c953 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/triggers/category-overspent/index.js @@ -0,0 +1,35 @@ +import { DateTime } from 'luxon'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Category overspent', + key: 'categoryOverspent', + pollInterval: 15, + description: + 'Triggers when a category exceeds its budget, resulting in a negative balance.', + + async run($) { + const monthYear = DateTime.now().toFormat('MM-yyyy'); + const categoryWithNegativeBalance = []; + + const response = await $.http.get('/budgets/default/categories'); + const categoryGroups = response.data.data.category_groups; + + categoryGroups.forEach((group) => { + group.categories.forEach((category) => { + if (category.balance < 0) { + categoryWithNegativeBalance.push(category); + } + }); + }); + + for (const category of categoryWithNegativeBalance) { + $.pushTriggerItem({ + raw: category, + meta: { + internalId: `${category.id}-${monthYear}`, + }, + }); + } + }, +}); diff --git a/packages/backend/src/apps/you-need-a-budget/triggers/goal-completed/index.js b/packages/backend/src/apps/you-need-a-budget/triggers/goal-completed/index.js new file mode 100644 index 0000000..53e8784 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/triggers/goal-completed/index.js @@ -0,0 +1,34 @@ +import { DateTime } from 'luxon'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Goal completed', + key: 'goalCompleted', + pollInterval: 15, + description: 'Triggers when a goal is completed.', + + async run($) { + const monthYear = DateTime.now().toFormat('MM-yyyy'); + const goalCompletedCategories = []; + + const response = await $.http.get('/budgets/default/categories'); + const categoryGroups = response.data.data.category_groups; + + categoryGroups.forEach((group) => { + group.categories.forEach((category) => { + if (category.goal_percentage_complete === 100) { + goalCompletedCategories.push(category); + } + }); + }); + + for (const category of goalCompletedCategories) { + $.pushTriggerItem({ + raw: category, + meta: { + internalId: `${category.id}-${monthYear}`, + }, + }); + } + }, +}); diff --git a/packages/backend/src/apps/you-need-a-budget/triggers/index.js b/packages/backend/src/apps/you-need-a-budget/triggers/index.js new file mode 100644 index 0000000..81ac969 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/triggers/index.js @@ -0,0 +1,11 @@ +import categoryOverspent from './category-overspent/index.js'; +import goalCompleted from './goal-completed/index.js'; +import lowAccountBalance from './low-account-balance/index.js'; +import newTransactions from './new-transactions/index.js'; + +export default [ + categoryOverspent, + goalCompleted, + lowAccountBalance, + newTransactions, +]; diff --git a/packages/backend/src/apps/you-need-a-budget/triggers/low-account-balance/index.js b/packages/backend/src/apps/you-need-a-budget/triggers/low-account-balance/index.js new file mode 100644 index 0000000..2eeacae --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/triggers/low-account-balance/index.js @@ -0,0 +1,41 @@ +import { DateTime } from 'luxon'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Low account balance', + key: 'lowAccountBalance', + pollInterval: 15, + description: + 'Triggers when the balance of a Checking or Savings account falls below a specified amount within a given month.', + arguments: [ + { + label: 'Balance Below Amount', + key: 'balanceBelowAmount', + type: 'string', + required: true, + description: 'Account balance falls below this amount (e.g. "250.00")', + variables: true, + }, + ], + + async run($) { + const monthYear = DateTime.now().toFormat('MM-yyyy'); + const balanceBelowAmount = $.step.parameters.balanceBelowAmount; + const formattedBalance = balanceBelowAmount * 1000; + + const response = await $.http.get('/budgets/default/accounts'); + + if (response.data?.data?.accounts?.length) { + for (const account of response.data.data.accounts) { + if (account.balance < formattedBalance) { + $.pushTriggerItem({ + raw: account, + meta: { + internalId: `${account.id}-${monthYear}`, + }, + }); + } + } + } + }, +}); diff --git a/packages/backend/src/apps/you-need-a-budget/triggers/new-transactions/index.js b/packages/backend/src/apps/you-need-a-budget/triggers/new-transactions/index.js new file mode 100644 index 0000000..00aa4c0 --- /dev/null +++ b/packages/backend/src/apps/you-need-a-budget/triggers/new-transactions/index.js @@ -0,0 +1,24 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New transactions', + key: 'newTransactions', + pollInterval: 15, + description: 'Triggers when a new transaction is created.', + + async run($) { + const response = await $.http.get('/budgets/default/transactions'); + const transactions = response.data.data?.transactions; + + if (transactions?.length) { + for (const transaction of transactions) { + $.pushTriggerItem({ + raw: transaction, + meta: { + internalId: transaction.id, + }, + }); + } + } + }, +}); diff --git a/packages/backend/src/apps/youtube/assets/favicon.svg b/packages/backend/src/apps/youtube/assets/favicon.svg new file mode 100644 index 0000000..e54d503 --- /dev/null +++ b/packages/backend/src/apps/youtube/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/youtube/auth/generate-auth-url.js b/packages/backend/src/apps/youtube/auth/generate-auth-url.js new file mode 100644 index 0000000..093f34b --- /dev/null +++ b/packages/backend/src/apps/youtube/auth/generate-auth-url.js @@ -0,0 +1,23 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + client_id: $.auth.data.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: authScope.join(' '), + access_type: 'offline', + prompt: 'select_account', + }); + + const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`; + + await $.auth.set({ + url, + }); +} diff --git a/packages/backend/src/apps/youtube/auth/index.js b/packages/backend/src/apps/youtube/auth/index.js new file mode 100644 index 0000000..0504b1d --- /dev/null +++ b/packages/backend/src/apps/youtube/auth/index.js @@ -0,0 +1,48 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import refreshToken from './refresh-token.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/youtube/connections/add', + placeholder: null, + description: + 'When asked to input a redirect URL in Google Cloud, enter the URL above.', + clickToCopy: true, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, + refreshToken, +}; diff --git a/packages/backend/src/apps/youtube/auth/is-still-verified.js b/packages/backend/src/apps/youtube/auth/is-still-verified.js new file mode 100644 index 0000000..68f4d7d --- /dev/null +++ b/packages/backend/src/apps/youtube/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + const currentUser = await getCurrentUser($); + return !!currentUser.resourceName; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/youtube/auth/refresh-token.js b/packages/backend/src/apps/youtube/auth/refresh-token.js new file mode 100644 index 0000000..7c5b702 --- /dev/null +++ b/packages/backend/src/apps/youtube/auth/refresh-token.js @@ -0,0 +1,26 @@ +import { URLSearchParams } from 'node:url'; + +import authScope from '../common/auth-scope.js'; + +const refreshToken = async ($) => { + const params = new URLSearchParams({ + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + grant_type: 'refresh_token', + refresh_token: $.auth.data.refreshToken, + }); + + const { data } = await $.http.post( + 'https://oauth2.googleapis.com/token', + params.toString() + ); + + await $.auth.set({ + accessToken: data.access_token, + expiresIn: data.expires_in, + scope: authScope.join(' '), + tokenType: data.token_type, + }); +}; + +export default refreshToken; diff --git a/packages/backend/src/apps/youtube/auth/verify-credentials.js b/packages/backend/src/apps/youtube/auth/verify-credentials.js new file mode 100644 index 0000000..a636b72 --- /dev/null +++ b/packages/backend/src/apps/youtube/auth/verify-credentials.js @@ -0,0 +1,42 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const verifyCredentials = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, { + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + code: $.auth.data.code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); + + await $.auth.set({ + accessToken: data.access_token, + tokenType: data.token_type, + }); + + const currentUser = await getCurrentUser($); + + const { displayName } = currentUser.names.find( + (name) => name.metadata.primary + ); + const { value: email } = currentUser.emailAddresses.find( + (emailAddress) => emailAddress.metadata.primary + ); + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + scope: $.auth.data.scope, + idToken: data.id_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + resourceName: currentUser.resourceName, + screenName: `${displayName} - ${email}`, + }); +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/youtube/common/add-auth-header.js b/packages/backend/src/apps/youtube/common/add-auth-header.js new file mode 100644 index 0000000..02477aa --- /dev/null +++ b/packages/backend/src/apps/youtube/common/add-auth-header.js @@ -0,0 +1,9 @@ +const addAuthHeader = ($, requestConfig) => { + if ($.auth.data?.accessToken) { + requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/youtube/common/auth-scope.js b/packages/backend/src/apps/youtube/common/auth-scope.js new file mode 100644 index 0000000..fdee549 --- /dev/null +++ b/packages/backend/src/apps/youtube/common/auth-scope.js @@ -0,0 +1,9 @@ +const authScope = [ + 'https://www.googleapis.com/auth/youtube', + 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/youtube.upload', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +export default authScope; diff --git a/packages/backend/src/apps/youtube/common/get-current-user.js b/packages/backend/src/apps/youtube/common/get-current-user.js new file mode 100644 index 0000000..2663ad2 --- /dev/null +++ b/packages/backend/src/apps/youtube/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const { data: currentUser } = await $.http.get( + 'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses' + ); + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/youtube/index.js b/packages/backend/src/apps/youtube/index.js new file mode 100644 index 0000000..f158168 --- /dev/null +++ b/packages/backend/src/apps/youtube/index.js @@ -0,0 +1,18 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-header.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'Youtube', + key: 'youtube', + baseUrl: 'https://www.youtube.com/', + apiBaseUrl: 'https://www.googleapis.com/youtube', + iconUrl: '{BASE_URL}/apps/youtube/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/youtube/connection', + primaryColor: '#FF0000', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, +}); diff --git a/packages/backend/src/apps/youtube/triggers/index.js b/packages/backend/src/apps/youtube/triggers/index.js new file mode 100644 index 0000000..e5b4e02 --- /dev/null +++ b/packages/backend/src/apps/youtube/triggers/index.js @@ -0,0 +1,4 @@ +import newVideoInChannel from './new-video-in-channel/index.js'; +import newVideoBySearch from './new-video-by-search/index.js'; + +export default [newVideoBySearch, newVideoInChannel]; diff --git a/packages/backend/src/apps/youtube/triggers/new-video-by-search/index.js b/packages/backend/src/apps/youtube/triggers/new-video-by-search/index.js new file mode 100644 index 0000000..4c7c645 --- /dev/null +++ b/packages/backend/src/apps/youtube/triggers/new-video-by-search/index.js @@ -0,0 +1,48 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New video by search', + key: 'newVideoBySearch', + pollInterval: 15, + description: + 'Triggers when a new video is uploaded that matches a specific search string.', + arguments: [ + { + label: 'Query', + key: 'query', + type: 'string', + required: true, + description: 'Search for videos that match this query.', + variables: true, + }, + ], + + async run($) { + const query = $.step.parameters.query; + + const params = { + pageToken: undefined, + part: 'snippet', + q: query, + maxResults: 50, + order: 'date', + type: 'video', + }; + + do { + const { data } = await $.http.get('/v3/search', { params }); + params.pageToken = data.nextPageToken; + + if (data?.items?.length) { + for (const item of data.items) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.etag, + }, + }); + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/youtube/triggers/new-video-in-channel/index.js b/packages/backend/src/apps/youtube/triggers/new-video-in-channel/index.js new file mode 100644 index 0000000..c048d52 --- /dev/null +++ b/packages/backend/src/apps/youtube/triggers/new-video-in-channel/index.js @@ -0,0 +1,49 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New video in channel', + key: 'newVideoInChannel', + pollInterval: 15, + description: + 'Triggers when a new video is published to a specific Youtube channel.', + arguments: [ + { + label: 'Channel', + key: 'channelId', + type: 'string', + required: true, + description: + 'Get the new videos uploaded to this channel. If the URL of the youtube channel looks like this www.youtube.com/channel/UCbxb2fqe9oNgglAoYqsYOtQ then you must use UCbxb2fqe9oNgglAoYqsYOtQ as a value in this field.', + variables: true, + }, + ], + + async run($) { + const channelId = $.step.parameters.channelId; + + const params = { + pageToken: undefined, + part: 'snippet', + channelId: channelId, + maxResults: 50, + order: 'date', + type: 'video', + }; + + do { + const { data } = await $.http.get('/v3/search', { params }); + params.pageToken = data.nextPageToken; + + if (data?.items?.length) { + for (const item of data.items) { + $.pushTriggerItem({ + raw: item, + meta: { + internalId: item.etag, + }, + }); + } + } + } while (params.pageToken); + }, +}); diff --git a/packages/backend/src/apps/zendesk/actions/create-ticket/fields.js b/packages/backend/src/apps/zendesk/actions/create-ticket/fields.js new file mode 100644 index 0000000..e40da2b --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/create-ticket/fields.js @@ -0,0 +1,301 @@ +export const fields = [ + { + label: 'Subject', + key: 'subject', + type: 'string', + required: true, + variables: true, + description: '', + }, + { + label: 'Assignee', + key: 'assigneeId', + type: 'dropdown', + required: false, + variables: true, + description: + 'Note: An error occurs if the assignee is not in the default group (or the specific group chosen below).', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + { + name: 'parameters.showUserRole', + value: 'true', + }, + { + name: 'parameters.includeAdmins', + value: 'true', + }, + ], + }, + }, + { + label: 'Collaborators', + key: 'collaborators', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Collaborator', + key: 'collaborator', + type: 'dropdown', + required: false, + variables: true, + description: '', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + { + name: 'parameters.includeAdmins', + value: 'true', + }, + ], + }, + }, + ], + }, + { + label: 'Collaborator Emails', + key: 'collaboratorEmails', + type: 'dynamic', + required: false, + description: + 'You have the option to include individuals who are not Zendesk users as Collaborators by adding their email addresses here.', + fields: [ + { + label: 'Collaborator Email', + key: 'collaboratorEmail', + type: 'string', + required: false, + variables: true, + description: '', + }, + ], + }, + { + label: 'Group', + key: 'groupId', + type: 'dropdown', + required: false, + variables: true, + description: 'Allocate this ticket to a specific group.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listGroups', + }, + ], + }, + }, + { + label: 'Requester Name', + key: 'requesterName', + type: 'string', + required: false, + variables: true, + description: + 'To specify the Requester, you need to fill in the Requester Name in this field and provide the Requestor Email in the next field.', + }, + { + label: 'Requester Email', + key: 'requesterEmail', + type: 'string', + required: false, + variables: true, + description: + 'To specify the Requester, you need to fill in the Requester Email in this field and provide the Requestor Name in the previous field.', + }, + { + label: 'First Comment/Description Format', + key: 'format', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Plain Text', value: 'Plain Text' }, + { label: 'HTML', value: 'HTML' }, + ], + }, + { + label: 'First Comment/Description', + key: 'comment', + type: 'string', + required: true, + variables: true, + description: '', + }, + { + label: 'Should the first comment be public?', + key: 'publicOrNot', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ], + }, + { + label: 'Tags', + key: 'tags', + type: 'string', + required: false, + variables: true, + description: 'A comma separated list of tags.', + }, + { + label: 'Status', + key: 'status', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'New', value: 'new' }, + { label: 'Open', value: 'open' }, + { label: 'Pending', value: 'pending' }, + { label: 'Hold', value: 'hold' }, + { label: 'Solved', value: 'solved' }, + { label: 'Closed', value: 'closed' }, + ], + }, + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Problem', value: 'problem' }, + { label: 'Incident', value: 'incident' }, + { label: 'Question', value: 'question' }, + { label: 'Task', value: 'task' }, + ], + }, + { + label: 'Due At', + key: 'dueAt', + type: 'string', + required: false, + variables: true, + description: 'Limited to tickets typed as "task".', + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Urgent', value: 'urgent' }, + { label: 'High', value: 'high' }, + { label: 'Normal', value: 'normal' }, + { label: 'Low', value: 'low' }, + ], + }, + { + label: 'Submitter', + key: 'submitterId', + type: 'dropdown', + required: false, + variables: true, + description: '', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + { + name: 'parameters.includeAdmins', + value: 'false', + }, + ], + }, + }, + { + label: 'Ticket Form', + key: 'ticketForm', + type: 'dropdown', + required: false, + variables: true, + description: + 'When chosen, this will configure the form displayed for this ticket. Note: This field is solely relevant for Zendesk enterprise accounts.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listTicketForms', + }, + ], + }, + }, + { + label: 'Sharing Agreements', + key: 'sharingAgreements', + type: 'dynamic', + required: false, + description: '', + fields: [ + { + label: 'Sharing Agreement', + key: 'sharingAgreement', + type: 'dropdown', + required: false, + variables: true, + description: '', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listSharingAgreements', + }, + ], + }, + }, + ], + }, + { + label: 'Brand', + key: 'brandId', + type: 'dropdown', + required: false, + variables: true, + description: + 'This applies exclusively to Zendesk customers subscribed to plans that include multi-brand support.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listBrands', + }, + ], + }, + }, +]; diff --git a/packages/backend/src/apps/zendesk/actions/create-ticket/index.js b/packages/backend/src/apps/zendesk/actions/create-ticket/index.js new file mode 100644 index 0000000..05e737e --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/create-ticket/index.js @@ -0,0 +1,93 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; +import isEmpty from 'lodash/isEmpty.js'; + +export default defineAction({ + name: 'Create ticket', + key: 'createTicket', + description: 'Creates a new ticket', + arguments: fields, + + async run($) { + const { + subject, + assigneeId, + groupId, + requesterName, + requesterEmail, + format, + comment, + publicOrNot, + status, + type, + dueAt, + priority, + submitterId, + ticketForm, + brandId, + } = $.step.parameters; + + const collaborators = $.step.parameters.collaborators; + const collaboratorIds = collaborators?.map( + (collaborator) => collaborator.collaborator + ); + + const collaboratorEmails = $.step.parameters.collaboratorEmails; + const formattedCollaboratorEmails = collaboratorEmails?.map( + (collaboratorEmail) => collaboratorEmail.collaboratorEmail + ); + + const formattedCollaborators = [ + ...collaboratorIds, + ...formattedCollaboratorEmails, + ]; + + const sharingAgreements = $.step.parameters.sharingAgreements; + const sharingAgreementIds = sharingAgreements + ?.filter(isEmpty) + .map((sharingAgreement) => Number(sharingAgreement.sharingAgreement)); + + const tags = $.step.parameters.tags; + const formattedTags = tags.split(','); + + const payload = { + ticket: { + subject, + assignee_id: assigneeId, + collaborators: formattedCollaborators, + group_id: groupId, + is_public: publicOrNot, + tags: formattedTags, + status, + type, + due_at: dueAt, + priority, + submitter_id: submitterId, + ticket_form_id: ticketForm, + sharing_agreement_ids: sharingAgreementIds, + brand_id: brandId, + }, + }; + + if (requesterName && requesterEmail) { + payload.ticket.requester = { + name: requesterName, + email: requesterEmail, + }; + } + + if (format === 'HTML') { + payload.ticket.comment = { + html_body: comment, + }; + } else { + payload.ticket.comment = { + body: comment, + }; + } + + const response = await $.http.post('/api/v2/tickets', payload); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/zendesk/actions/create-user/fields.js b/packages/backend/src/apps/zendesk/actions/create-user/fields.js new file mode 100644 index 0000000..1629a86 --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/create-user/fields.js @@ -0,0 +1,102 @@ +export const fields = [ + { + label: 'Name', + key: 'name', + type: 'string', + required: true, + variables: true, + description: '', + }, + { + label: 'Email', + key: 'email', + type: 'string', + required: true, + variables: true, + description: + 'It is essential to be distinctive. Zendesk prohibits the existence of identical users sharing the same email address.', + }, + { + label: 'Details', + key: 'details', + type: 'string', + required: false, + variables: true, + description: '', + }, + { + label: 'Notes', + key: 'notes', + type: 'string', + required: false, + variables: true, + description: + 'Within this field, you have the capability to save any remarks or comments you may have concerning the user.', + }, + { + label: 'Phone', + key: 'phone', + type: 'string', + required: false, + variables: true, + description: + "The user's contact number should be entered in the following format: +1 (555) 123-4567.", + }, + { + label: 'Tags', + key: 'tags', + type: 'string', + required: false, + variables: true, + description: 'A comma separated list of tags.', + }, + { + label: 'Role', + key: 'role', + type: 'string', + required: false, + variables: true, + description: + "It can take on one of the designated roles: 'end-user', 'agent', or 'admin'. If a different value is set or none is specified, the default is 'end-user.'", + }, + { + label: 'Organization', + key: 'organizationId', + type: 'dropdown', + required: false, + variables: true, + description: 'Assign this user to a specific organization.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listOrganizations', + }, + ], + }, + }, + { + label: 'External Id', + key: 'externalId', + type: 'string', + required: false, + variables: true, + description: + 'An exclusive external identifier; you can utilize this to link organizations with an external record.', + }, + { + label: 'Verified', + key: 'verified', + type: 'dropdown', + required: false, + description: + "Specify if you can verify that the user's assertion of their identity is accurate.", + variables: true, + options: [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ], + }, +]; diff --git a/packages/backend/src/apps/zendesk/actions/create-user/index.js b/packages/backend/src/apps/zendesk/actions/create-user/index.js new file mode 100644 index 0000000..3a02ddf --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/create-user/index.js @@ -0,0 +1,48 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; + +export default defineAction({ + name: 'Create user', + key: 'createUser', + description: 'Creates a new user.', + arguments: fields, + + async run($) { + const { + name, + email, + details, + notes, + phone, + role, + organizationId, + externalId, + verified, + } = $.step.parameters; + + const tags = $.step.parameters.tags; + const formattedTags = tags.split(','); + + const payload = { + user: { + name, + email, + details, + notes, + phone, + organization_id: organizationId, + external_id: externalId, + verified: verified || 'false', + tags: formattedTags, + }, + }; + + if (role) { + payload.user.role = role; + } + + const response = await $.http.post('/api/v2/users', payload); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/zendesk/actions/delete-ticket/index.js b/packages/backend/src/apps/zendesk/actions/delete-ticket/index.js new file mode 100644 index 0000000..52ebeb9 --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/delete-ticket/index.js @@ -0,0 +1,35 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Delete ticket', + key: 'deleteTicket', + description: 'Deletes an existing ticket.', + arguments: [ + { + label: 'Ticket', + key: 'ticketId', + type: 'dropdown', + required: true, + variables: true, + description: 'Select the ticket you want to delete.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFirstPageOfTickets', + }, + ], + }, + }, + ], + + async run($) { + const ticketId = $.step.parameters.ticketId; + + const response = await $.http.delete(`/api/v2/tickets/${ticketId}`); + + $.setActionItem({ raw: { data: response.data } }); + }, +}); diff --git a/packages/backend/src/apps/zendesk/actions/delete-user/index.js b/packages/backend/src/apps/zendesk/actions/delete-user/index.js new file mode 100644 index 0000000..8e4fbfd --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/delete-user/index.js @@ -0,0 +1,43 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Delete user', + key: 'deleteUser', + description: 'Deletes an existing user.', + arguments: [ + { + label: 'User', + key: 'userId', + type: 'dropdown', + required: true, + variables: true, + description: 'Select the user you want to modify.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + { + name: 'parameters.showUserRole', + value: 'true', + }, + { + name: 'parameters.includeAllUsers', + value: 'true', + }, + ], + }, + }, + ], + + async run($) { + const userId = $.step.parameters.userId; + + const response = await $.http.delete(`/api/v2/users/${userId}`); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/zendesk/actions/find-ticket/index.js b/packages/backend/src/apps/zendesk/actions/find-ticket/index.js new file mode 100644 index 0000000..40f6eec --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/find-ticket/index.js @@ -0,0 +1,32 @@ +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Find ticket', + key: 'findTicket', + description: 'Finds an existing ticket.', + arguments: [ + { + label: 'Query', + key: 'query', + type: 'string', + required: true, + variables: true, + description: + 'Write a search string that specifies the way we will search for the ticket in Zendesk.', + }, + ], + + async run($) { + const query = $.step.parameters.query; + + const params = { + query: `type:ticket ${query}`, + sort_by: 'created_at', + sort_order: 'desc', + }; + + const response = await $.http.get('/api/v2/search', { params }); + + $.setActionItem({ raw: response.data.results[0] }); + }, +}); diff --git a/packages/backend/src/apps/zendesk/actions/index.js b/packages/backend/src/apps/zendesk/actions/index.js new file mode 100644 index 0000000..2307913 --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/index.js @@ -0,0 +1,15 @@ +import createTicket from './create-ticket/index.js'; +import createUser from './create-user/index.js'; +import deleteTicket from './delete-ticket/index.js'; +import deleteUser from './delete-user/index.js'; +import findTicket from './find-ticket/index.js'; +import updateTicket from './update-ticket/index.js'; + +export default [ + createTicket, + createUser, + deleteTicket, + deleteUser, + findTicket, + updateTicket, +]; diff --git a/packages/backend/src/apps/zendesk/actions/update-ticket/fields.js b/packages/backend/src/apps/zendesk/actions/update-ticket/fields.js new file mode 100644 index 0000000..29f646c --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/update-ticket/fields.js @@ -0,0 +1,167 @@ +export const fields = [ + { + label: 'Ticket', + key: 'ticketId', + type: 'dropdown', + required: true, + variables: true, + description: 'Select the ticket you want to change.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listFirstPageOfTickets', + }, + ], + }, + }, + { + label: 'Subject', + key: 'subject', + type: 'string', + required: false, + variables: true, + description: '', + }, + { + label: 'Assignee', + key: 'assigneeId', + type: 'dropdown', + required: false, + variables: true, + description: + 'Note: An error occurs if the assignee is not in the default group (or the specific group chosen below).', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + { + name: 'parameters.showUserRole', + value: 'true', + }, + { + name: 'parameters.includeAdmins', + value: 'true', + }, + ], + }, + }, + { + label: 'Group', + key: 'groupId', + type: 'dropdown', + required: false, + variables: true, + description: 'Allocate this ticket to a specific group.', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listGroups', + }, + ], + }, + }, + { + label: 'New Status', + key: 'status', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'New', value: 'new' }, + { label: 'Open', value: 'open' }, + { label: 'Pending', value: 'pending' }, + { label: 'Hold', value: 'hold' }, + { label: 'Solved', value: 'solved' }, + { label: 'Closed', value: 'closed' }, + ], + }, + { + label: 'New comment to add to the ticket', + key: 'comment', + type: 'string', + required: false, + variables: true, + description: '', + }, + { + label: 'Should the first comment be public?', + key: 'publicOrNot', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ], + }, + { + label: 'Tags', + key: 'tags', + type: 'string', + required: false, + variables: true, + description: 'A comma separated list of tags.', + }, + { + label: 'Type', + key: 'type', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Problem', value: 'problem' }, + { label: 'Incident', value: 'incident' }, + { label: 'Question', value: 'question' }, + { label: 'Task', value: 'task' }, + ], + }, + { + label: 'Priority', + key: 'priority', + type: 'dropdown', + required: false, + variables: true, + description: '', + options: [ + { label: 'Urgent', value: 'urgent' }, + { label: 'High', value: 'high' }, + { label: 'Normal', value: 'normal' }, + { label: 'Low', value: 'low' }, + ], + }, + { + label: 'Submitter', + key: 'submitterId', + type: 'dropdown', + required: false, + variables: true, + description: '', + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listUsers', + }, + { + name: 'parameters.includeAdmins', + value: 'false', + }, + ], + }, + }, +]; diff --git a/packages/backend/src/apps/zendesk/actions/update-ticket/index.js b/packages/backend/src/apps/zendesk/actions/update-ticket/index.js new file mode 100644 index 0000000..cff7e0e --- /dev/null +++ b/packages/backend/src/apps/zendesk/actions/update-ticket/index.js @@ -0,0 +1,57 @@ +import defineAction from '../../../../helpers/define-action.js'; +import { fields } from './fields.js'; +import isEmpty from 'lodash/isEmpty.js'; +import omitBy from 'lodash/omitBy.js'; + +export default defineAction({ + name: 'Update ticket', + key: 'updateTicket', + description: 'Modify the status of an existing ticket or append comments.', + arguments: fields, + + async run($) { + const { + ticketId, + subject, + assigneeId, + groupId, + status, + comment, + publicOrNot, + type, + priority, + submitterId, + } = $.step.parameters; + + const tags = $.step.parameters.tags; + const formattedTags = tags.split(','); + + const payload = { + subject, + assignee_id: assigneeId, + group_id: groupId, + status, + comment: { + body: comment, + public: publicOrNot, + }, + tags: formattedTags, + type, + priority, + submitter_id: submitterId, + }; + + const fieldsToRemoveIfEmpty = ['group_id', 'status', 'type', 'priority']; + + const filteredPayload = omitBy( + payload, + (value, key) => fieldsToRemoveIfEmpty.includes(key) && isEmpty(value) + ); + + const response = await $.http.put(`/api/v2/tickets/${ticketId}`, { + ticket: filteredPayload, + }); + + $.setActionItem({ raw: response.data }); + }, +}); diff --git a/packages/backend/src/apps/zendesk/assets/favicon.svg b/packages/backend/src/apps/zendesk/assets/favicon.svg new file mode 100644 index 0000000..882b451 --- /dev/null +++ b/packages/backend/src/apps/zendesk/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/backend/src/apps/zendesk/auth/generate-auth-url.js b/packages/backend/src/apps/zendesk/auth/generate-auth-url.js new file mode 100644 index 0000000..9e93282 --- /dev/null +++ b/packages/backend/src/apps/zendesk/auth/generate-auth-url.js @@ -0,0 +1,22 @@ +import { URLSearchParams } from 'url'; +import authScope from '../common/auth-scope.js'; + +export default async function generateAuthUrl($) { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + + const redirectUri = oauthRedirectUrlField.value; + const searchParams = new URLSearchParams({ + response_type: 'code', + redirect_uri: redirectUri, + client_id: $.auth.data.clientId, + scope: authScope.join(' '), + }); + + await $.auth.set({ + url: `${ + $.auth.data.instanceUrl + }/oauth/authorizations/new?${searchParams.toString()}`, + }); +} diff --git a/packages/backend/src/apps/zendesk/auth/index.js b/packages/backend/src/apps/zendesk/auth/index.js new file mode 100644 index 0000000..67df59a --- /dev/null +++ b/packages/backend/src/apps/zendesk/auth/index.js @@ -0,0 +1,55 @@ +import generateAuthUrl from './generate-auth-url.js'; +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/zendesk/connections/add', + placeholder: null, + description: '', + clickToCopy: true, + }, + { + key: 'instanceUrl', + label: 'Zendesk Subdomain Url', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: 'https://{{subdomain}}.zendesk.com', + clickToCopy: false, + }, + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + + generateAuthUrl, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/zendesk/auth/is-still-verified.js b/packages/backend/src/apps/zendesk/auth/is-still-verified.js new file mode 100644 index 0000000..5400157 --- /dev/null +++ b/packages/backend/src/apps/zendesk/auth/is-still-verified.js @@ -0,0 +1,8 @@ +import getCurrentUser from '../common/get-current-user.js'; + +const isStillVerified = async ($) => { + await getCurrentUser($); + return true; +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/zendesk/auth/verify-credentials.js b/packages/backend/src/apps/zendesk/auth/verify-credentials.js new file mode 100644 index 0000000..3a416a6 --- /dev/null +++ b/packages/backend/src/apps/zendesk/auth/verify-credentials.js @@ -0,0 +1,55 @@ +import getCurrentUser from '../common/get-current-user.js'; +import scopes from '../common/auth-scope.js'; + +const verifyCredentials = async ($) => { + await getAccessToken($); + + const user = await getCurrentUser($); + const subdomain = extractSubdomain($.auth.data.instanceUrl); + const name = user.name; + const screenName = [name, subdomain].filter(Boolean).join(' @ '); + + await $.auth.set({ + screenName, + apiToken: $.auth.data.apiToken, + instanceUrl: $.auth.data.instanceUrl, + email: $.auth.data.email, + }); +}; + +const getAccessToken = async ($) => { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field) => field.key == 'oAuthRedirectUrl' + ); + const redirectUri = oauthRedirectUrlField.value; + + const response = await $.http.post(`/oauth/tokens`, { + redirect_uri: redirectUri, + code: $.auth.data.code, + grant_type: 'authorization_code', + scope: scopes.join(' '), + client_id: $.auth.data.clientId, + client_secret: $.auth.data.clientSecret, + }); + + const data = response.data; + + $.auth.data.accessToken = data.access_token; + + await $.auth.set({ + clientId: $.auth.data.clientId, + clientSecret: $.auth.data.clientSecret, + accessToken: data.access_token, + tokenType: data.token_type, + }); +}; + +function extractSubdomain(url) { + const match = url.match(/https:\/\/(.*?)\.zendesk\.com/); + if (match && match[1]) { + return match[1]; + } + return null; +} + +export default verifyCredentials; diff --git a/packages/backend/src/apps/zendesk/common/add-auth-headers.js b/packages/backend/src/apps/zendesk/common/add-auth-headers.js new file mode 100644 index 0000000..96a0e04 --- /dev/null +++ b/packages/backend/src/apps/zendesk/common/add-auth-headers.js @@ -0,0 +1,15 @@ +const addAuthHeader = ($, requestConfig) => { + const { instanceUrl, tokenType, accessToken } = $.auth.data; + + if (instanceUrl) { + requestConfig.baseURL = instanceUrl; + } + + if (tokenType && accessToken) { + requestConfig.headers.Authorization = `${tokenType} ${$.auth.data.accessToken}`; + } + + return requestConfig; +}; + +export default addAuthHeader; diff --git a/packages/backend/src/apps/zendesk/common/auth-scope.js b/packages/backend/src/apps/zendesk/common/auth-scope.js new file mode 100644 index 0000000..855e337 --- /dev/null +++ b/packages/backend/src/apps/zendesk/common/auth-scope.js @@ -0,0 +1,3 @@ +const authScope = ['read', 'write']; + +export default authScope; diff --git a/packages/backend/src/apps/zendesk/common/get-current-user.js b/packages/backend/src/apps/zendesk/common/get-current-user.js new file mode 100644 index 0000000..5741b02 --- /dev/null +++ b/packages/backend/src/apps/zendesk/common/get-current-user.js @@ -0,0 +1,8 @@ +const getCurrentUser = async ($) => { + const response = await $.http.get('/api/v2/users/me'); + const currentUser = response.data.user; + + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/index.js b/packages/backend/src/apps/zendesk/dynamic-data/index.js new file mode 100644 index 0000000..d32b6fb --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/index.js @@ -0,0 +1,20 @@ +import listUsers from './list-users/index.js'; +import listBrands from './list-brands/index.js'; +import listFirstPageOfTickets from './list-first-page-of-tickets/index.js'; +import listGroups from './list-groups/index.js'; +import listOrganizations from './list-organizations/index.js'; +import listSharingAgreements from './list-sharing-agreements/index.js'; +import listTicketForms from './list-ticket-forms/index.js'; +import listViews from './list-views/index.js'; + +export default [ + listUsers, + listBrands, + listFirstPageOfTickets, + listGroups, + listOrganizations, + listSharingAgreements, + listFirstPageOfTickets, + listTicketForms, + listViews, +]; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-brands/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-brands/index.js new file mode 100644 index 0000000..a5a170c --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-brands/index.js @@ -0,0 +1,34 @@ +export default { + name: 'List brands', + key: 'listBrands', + + async run($) { + const brands = { + data: [], + }; + + const params = { + page: 1, + per_page: 100, + }; + + let nextPage; + do { + const response = await $.http.get('/api/v2/brands', { params }); + const allBrands = response?.data?.brands; + nextPage = response.data.next_page; + params.page = params.page + 1; + + if (allBrands?.length) { + for (const brand of allBrands) { + brands.data.push({ + value: brand.id, + name: brand.name, + }); + } + } + } while (nextPage); + + return brands; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-first-page-of-tickets/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-first-page-of-tickets/index.js new file mode 100644 index 0000000..bf065a5 --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-first-page-of-tickets/index.js @@ -0,0 +1,29 @@ +export default { + name: 'List first page of tickets', + key: 'listFirstPageOfTickets', + + async run($) { + const tickets = { + data: [], + }; + + const params = { + 'page[size]': 100, + sort: '-id', + }; + + const response = await $.http.get('/api/v2/tickets', { params }); + const allTickets = response.data.tickets; + + if (allTickets?.length) { + for (const ticket of allTickets) { + tickets.data.push({ + value: ticket.id, + name: ticket.subject, + }); + } + } + + return tickets; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-groups/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-groups/index.js new file mode 100644 index 0000000..9fda32d --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-groups/index.js @@ -0,0 +1,34 @@ +export default { + name: 'List groups', + key: 'listGroups', + + async run($) { + const groups = { + data: [], + }; + let hasMore; + + const params = { + 'page[size]': 100, + 'page[after]': undefined, + }; + + do { + const response = await $.http.get('/api/v2/groups', { params }); + const allGroups = response?.data?.groups; + hasMore = response?.data?.meta?.has_more; + params['page[after]'] = response.data.meta?.after_cursor; + + if (allGroups?.length) { + for (const group of allGroups) { + groups.data.push({ + value: group.id, + name: group.name, + }); + } + } + } while (hasMore); + + return groups; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-organizations/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-organizations/index.js new file mode 100644 index 0000000..a6c7a4d --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-organizations/index.js @@ -0,0 +1,34 @@ +export default { + name: 'List organizations', + key: 'listOrganizations', + + async run($) { + const organizations = { + data: [], + }; + let hasMore; + + const params = { + 'page[size]': 100, + 'page[after]': undefined, + }; + + do { + const response = await $.http.get('/api/v2/organizations', { params }); + const allOrganizations = response?.data?.organizations; + hasMore = response?.data?.meta?.has_more; + params['page[after]'] = response.data.meta?.after_cursor; + + if (allOrganizations?.length) { + for (const organization of allOrganizations) { + organizations.data.push({ + value: organization.id, + name: organization.name, + }); + } + } + } while (hasMore); + + return organizations; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-sharing-agreements/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-sharing-agreements/index.js new file mode 100644 index 0000000..690f46e --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-sharing-agreements/index.js @@ -0,0 +1,36 @@ +export default { + name: 'List sharing agreements', + key: 'listSharingAgreements', + + async run($) { + const sharingAgreements = { + data: [], + }; + + const params = { + page: 1, + per_page: 100, + }; + + let nextPage; + do { + const response = await $.http.get('/api/v2/sharing_agreements', { + params, + }); + const allSharingAgreements = response?.data?.sharing_agreements; + nextPage = response.data.next_page; + params.page = params.page + 1; + + if (allSharingAgreements?.length) { + for (const sharingAgreement of allSharingAgreements) { + sharingAgreements.data.push({ + value: sharingAgreement.id, + name: sharingAgreement.name, + }); + } + } + } while (nextPage); + + return sharingAgreements; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-ticket-forms/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-ticket-forms/index.js new file mode 100644 index 0000000..ad102b9 --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-ticket-forms/index.js @@ -0,0 +1,34 @@ +export default { + name: 'List ticket forms', + key: 'listTicketForms', + + async run($) { + const ticketForms = { + data: [], + }; + + const params = { + page: 1, + per_page: 100, + }; + + let nextPage; + do { + const response = await $.http.get('/api/v2/ticket_forms', { params }); + const allTicketForms = response?.data?.ticket_forms; + nextPage = response.data.next_page; + params.page = params.page + 1; + + if (allTicketForms?.length) { + for (const ticketForm of allTicketForms) { + ticketForms.data.push({ + value: ticketForm.id, + name: ticketForm.name, + }); + } + } + } while (nextPage); + + return ticketForms; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-users/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-users/index.js new file mode 100644 index 0000000..efcafbb --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-users/index.js @@ -0,0 +1,39 @@ +export default { + name: 'List users', + key: 'listUsers', + + async run($) { + const users = { + data: [], + }; + let hasMore; + const showUserRole = $.step.parameters.showUserRole === 'true'; + const includeAdmins = $.step.parameters.includeAdmins === 'true'; + const role = includeAdmins ? ['admin', 'agent'] : ['agent']; + + const params = { + 'page[size]': 100, + role, + 'page[after]': undefined, + }; + + do { + const response = await $.http.get('/api/v2/users', { params }); + const allUsers = response?.data?.users; + hasMore = response?.data?.meta?.has_more; + params['page[after]'] = response.data.meta?.after_cursor; + + if (allUsers?.length) { + for (const user of allUsers) { + const name = showUserRole ? `${user.name} ${user.role}` : user.name; + users.data.push({ + value: user.id, + name, + }); + } + } + } while (hasMore); + + return users; + }, +}; diff --git a/packages/backend/src/apps/zendesk/dynamic-data/list-views/index.js b/packages/backend/src/apps/zendesk/dynamic-data/list-views/index.js new file mode 100644 index 0000000..fd7066a --- /dev/null +++ b/packages/backend/src/apps/zendesk/dynamic-data/list-views/index.js @@ -0,0 +1,34 @@ +export default { + name: 'List views', + key: 'listViews', + + async run($) { + const views = { + data: [], + }; + let hasMore; + + const params = { + 'page[size]': 100, + 'page[after]': undefined, + }; + + do { + const response = await $.http.get('/api/v2/views', { params }); + const allViews = response?.data?.views; + hasMore = response?.data?.meta?.has_more; + params['page[after]'] = response.data.meta?.after_cursor; + + if (allViews?.length) { + for (const view of allViews) { + views.data.push({ + value: view.id, + name: view.title, + }); + } + } + } while (hasMore); + + return views; + }, +}; diff --git a/packages/backend/src/apps/zendesk/index.js b/packages/backend/src/apps/zendesk/index.js new file mode 100644 index 0000000..37e12ea --- /dev/null +++ b/packages/backend/src/apps/zendesk/index.js @@ -0,0 +1,22 @@ +import defineApp from '../../helpers/define-app.js'; +import addAuthHeader from './common/add-auth-headers.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; +import dynamicData from './dynamic-data/index.js'; + +export default defineApp({ + name: 'Zendesk', + key: 'zendesk', + baseUrl: 'https://zendesk.com/', + apiBaseUrl: '', + iconUrl: '{BASE_URL}/apps/zendesk/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/zendesk/connection', + primaryColor: '#17494d', + supportsConnections: true, + beforeRequest: [addAuthHeader], + auth, + triggers, + actions, + dynamicData, +}); diff --git a/packages/backend/src/apps/zendesk/triggers/index.js b/packages/backend/src/apps/zendesk/triggers/index.js new file mode 100644 index 0000000..0006b36 --- /dev/null +++ b/packages/backend/src/apps/zendesk/triggers/index.js @@ -0,0 +1,4 @@ +import newTickets from './new-tickets/index.js'; +import newUsers from './new-users/index.js'; + +export default [newTickets, newUsers]; diff --git a/packages/backend/src/apps/zendesk/triggers/new-tickets/index.js b/packages/backend/src/apps/zendesk/triggers/new-tickets/index.js new file mode 100644 index 0000000..31156a1 --- /dev/null +++ b/packages/backend/src/apps/zendesk/triggers/new-tickets/index.js @@ -0,0 +1,59 @@ +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New tickets', + key: 'newTickets', + pollInterval: 15, + description: 'Triggers when a new ticket is created in a specific view.', + arguments: [ + { + label: 'View', + key: 'viewId', + type: 'dropdown', + required: true, + variables: true, + source: { + type: 'query', + name: 'getDynamicData', + arguments: [ + { + name: 'key', + value: 'listViews', + }, + ], + }, + }, + ], + + async run($) { + const viewId = $.step.parameters.viewId; + + const params = { + 'page[size]': 100, + 'page[after]': undefined, + sort_by: 'nice_id', + sort_order: 'desc', + }; + let hasMore; + + do { + const response = await $.http.get(`/api/v2/views/${viewId}/tickets`, { + params, + }); + const allTickets = response?.data?.tickets; + hasMore = response?.data?.meta?.has_more; + params['page[after]'] = response.data.meta?.after_cursor; + + if (allTickets?.length) { + for (const ticket of allTickets) { + $.pushTriggerItem({ + raw: ticket, + meta: { + internalId: ticket.id.toString(), + }, + }); + } + } + } while (hasMore); + }, +}); diff --git a/packages/backend/src/apps/zendesk/triggers/new-users/index.js b/packages/backend/src/apps/zendesk/triggers/new-users/index.js new file mode 100644 index 0000000..8610fed --- /dev/null +++ b/packages/backend/src/apps/zendesk/triggers/new-users/index.js @@ -0,0 +1,83 @@ +import Crypto from 'crypto'; +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'New users', + key: 'newUsers', + type: 'webhook', + description: 'Triggers upon the creation of a new user.', + + async run($) { + const dataItem = { + raw: $.request.body, + meta: { + internalId: Crypto.randomUUID(), + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async testRun($) { + const params = { + query: 'type:user', + sort_by: 'created_at', + sort_order: 'desc', + }; + + const response = await $.http.get('/api/v2/search', { params }); + + const lastUser = response.data.results[0]; + + const computedWebhookEvent = { + id: Crypto.randomUUID(), + time: lastUser.created_at, + type: 'zen:event-type:user.created', + event: {}, + detail: { + id: lastUser.id, + role: lastUser.role, + email: lastUser.email, + created_at: lastUser.created_at, + updated_at: lastUser.updated_at, + external_id: lastUser.external_id, + organization_id: lastUser.organization_id, + default_group_id: lastUser.default_group_id, + }, + subject: `zen:user:${lastUser.id}`, + account_id: '', + zendesk_event_version: '2022-11-06', + }; + + const dataItem = { + raw: computedWebhookEvent, + meta: { + internalId: computedWebhookEvent.id, + }, + }; + + $.pushTriggerItem(dataItem); + }, + + async registerHook($) { + const payload = { + webhook: { + name: `Flow ID: ${$.flow.id}`, + status: 'active', + subscriptions: ['zen:event-type:user.created'], + endpoint: $.webhookUrl, + http_method: 'POST', + request_format: 'json', + }, + }; + + const response = await $.http.post('/api/v2/webhooks', payload); + const id = response.data.webhook.id; + + await $.flow.setRemoteWebhookId(id); + }, + + async unregisterHook($) { + await $.http.delete(`/api/v2/webhooks/${$.flow.remoteWebhookId}`); + }, +}); diff --git a/packages/backend/src/config/app.js b/packages/backend/src/config/app.js new file mode 100644 index 0000000..6a4a7de --- /dev/null +++ b/packages/backend/src/config/app.js @@ -0,0 +1,123 @@ +import { URL } from 'node:url'; +import * as dotenv from 'dotenv'; +import path from 'path'; +import process from 'node:process'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +if (process.env.APP_ENV === 'test') { + dotenv.config({ path: path.resolve(__dirname, '../../.env.test') }); +} else { + dotenv.config(); +} + +const host = process.env.HOST || 'localhost'; +const protocol = process.env.PROTOCOL || 'http'; +const port = process.env.PORT || '3000'; +const serveWebAppSeparately = + process.env.SERVE_WEB_APP_SEPARATELY === 'true' ? true : false; + +let apiUrl = new URL( + process.env.API_URL || `${protocol}://${host}:${port}` +).toString(); +apiUrl = apiUrl.substring(0, apiUrl.length - 1); + +// use apiUrl by default, which has less priority over the following cases +let webAppUrl = apiUrl; + +if (process.env.WEB_APP_URL) { + // use env. var. if provided + webAppUrl = new URL(process.env.WEB_APP_URL).toString(); + webAppUrl = webAppUrl.substring(0, webAppUrl.length - 1); +} else if (serveWebAppSeparately) { + // no env. var. and serving separately, sign of development + webAppUrl = 'http://localhost:3001'; +} + +let webhookUrl = new URL(process.env.WEBHOOK_URL || apiUrl).toString(); +webhookUrl = webhookUrl.substring(0, webhookUrl.length - 1); + +const publicDocsUrl = 'https://automatisch.io/docs'; +const docsUrl = process.env.DOCS_URL || publicDocsUrl; + +const appEnv = process.env.APP_ENV || 'development'; + +const appConfig = { + host, + protocol, + port, + appEnv: appEnv, + logLevel: process.env.LOG_LEVEL || 'info', + isDev: appEnv === 'development', + isTest: appEnv === 'test', + isProd: appEnv === 'production', + version: '0.14.0', + postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development', + postgresSchema: process.env.POSTGRES_SCHEMA || 'public', + postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'), + postgresHost: process.env.POSTGRES_HOST || 'localhost', + postgresUsername: + process.env.POSTGRES_USERNAME || 'automatisch_development_user', + postgresPassword: process.env.POSTGRES_PASSWORD, + postgresEnableSsl: process.env.POSTGRES_ENABLE_SSL === 'true', + encryptionKey: process.env.ENCRYPTION_KEY || '', + webhookSecretKey: process.env.WEBHOOK_SECRET_KEY || '', + appSecretKey: process.env.APP_SECRET_KEY || '', + serveWebAppSeparately, + redisHost: process.env.REDIS_HOST || '127.0.0.1', + redisName: process.env.REDIS_NAME || 'mymaster', + redisPort: parseInt(process.env.REDIS_PORT || '6379'), + redisUsername: process.env.REDIS_USERNAME, + redisPassword: process.env.REDIS_PASSWORD, + redisDb: parseInt(process.env.REDIS_DB || '0'), + redisRole: process.env.REDIS_ROLE || 'master', + redisTls: process.env.REDIS_TLS === 'true', + redisSentinelHost: process.env.REDIS_SENTINEL_HOST, + redisSentinelUsername: process.env.REDIS_SENTINEL_USERNAME, + redisSentinelPassword: process.env.REDIS_SENTINEL_PASSWORD, + redisSentinelPort: parseInt(process.env.REDIS_SENTINEL_PORT || '26379'), + enableBullMQDashboard: process.env.ENABLE_BULLMQ_DASHBOARD === 'true', + bullMQDashboardUsername: process.env.BULLMQ_DASHBOARD_USERNAME, + bullMQDashboardPassword: process.env.BULLMQ_DASHBOARD_PASSWORD, + baseUrl: apiUrl, + webAppUrl, + webhookUrl, + docsUrl, + telemetryEnabled: process.env.TELEMETRY_ENABLED === 'false' ? false : true, + requestBodySizeLimit: '1mb', + smtpHost: process.env.SMTP_HOST, + smtpPort: parseInt(process.env.SMTP_PORT || '587'), + smtpSecure: process.env.SMTP_SECURE === 'true', + smtpUser: process.env.SMTP_USER, + smtpPassword: process.env.SMTP_PASSWORD, + fromEmail: process.env.FROM_EMAIL, + isCloud: process.env.AUTOMATISCH_CLOUD === 'true', + isSelfHosted: process.env.AUTOMATISCH_CLOUD !== 'true', + isMation: process.env.MATION === 'true', + paddleVendorId: Number(process.env.PADDLE_VENDOR_ID), + paddleVendorAuthCode: process.env.PADDLE_VENDOR_AUTH_CODE, + paddlePublicKey: process.env.PADDLE_PUBLIC_KEY, + licenseKey: process.env.LICENSE_KEY, + sentryDsn: process.env.SENTRY_DSN, + CI: process.env.CI === 'true', + disableNotificationsPage: process.env.DISABLE_NOTIFICATIONS_PAGE === 'true', + disableFavicon: process.env.DISABLE_FAVICON === 'true', + additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK, + additionalDrawerLinkIcon: process.env.ADDITIONAL_DRAWER_LINK_ICON, + additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT, + disableSeedUser: process.env.DISABLE_SEED_USER === 'true', + httpProxy: process.env.http_proxy, + httpsProxy: process.env.https_proxy, + noProxy: process.env.no_proxy, +}; + +if (!appConfig.encryptionKey) { + throw new Error('ENCRYPTION_KEY environment variable needs to be set!'); +} + +if (!appConfig.webhookSecretKey) { + throw new Error('WEBHOOK_SECRET_KEY environment variable needs to be set!'); +} + +export default appConfig; diff --git a/packages/backend/src/config/cors-options.js b/packages/backend/src/config/cors-options.js new file mode 100644 index 0000000..0ad0870 --- /dev/null +++ b/packages/backend/src/config/cors-options.js @@ -0,0 +1,10 @@ +import appConfig from './app.js'; + +const corsOptions = { + origin: appConfig.webAppUrl, + methods: 'GET,HEAD,POST,PATCH,DELETE', + credentials: true, + optionsSuccessStatus: 200, +}; + +export default corsOptions; diff --git a/packages/backend/src/config/database.js b/packages/backend/src/config/database.js new file mode 100644 index 0000000..55713b9 --- /dev/null +++ b/packages/backend/src/config/database.js @@ -0,0 +1,22 @@ +import process from 'process'; +// The following two lines are required to get count values as number. +// More info: https://github.com/knex/knex/issues/387#issuecomment-51554522 +import pg from 'pg'; +pg.types.setTypeParser(20, 'text', parseInt); +import knex from 'knex'; +import knexConfig from '../../knexfile.js'; +import logger from '../helpers/logger.js'; + +export const client = knex(knexConfig); + +const CONNECTION_REFUSED = 'ECONNREFUSED'; + +client.raw('SELECT 1').catch((err) => { + if (err.code === CONNECTION_REFUSED) { + logger.error( + 'Make sure you have installed PostgreSQL and it is running.', + err + ); + process.exit(); + } +}); diff --git a/packages/backend/src/config/orm.js b/packages/backend/src/config/orm.js new file mode 100644 index 0000000..a2576e7 --- /dev/null +++ b/packages/backend/src/config/orm.js @@ -0,0 +1,4 @@ +import { Model } from 'objection'; +import { client } from './database.js'; + +Model.knex(client); diff --git a/packages/backend/src/config/redis.js b/packages/backend/src/config/redis.js new file mode 100644 index 0000000..09f0ced --- /dev/null +++ b/packages/backend/src/config/redis.js @@ -0,0 +1,32 @@ +import appConfig from './app.js'; + +const redisConfig = { + username: appConfig.redisUsername, + password: appConfig.redisPassword, + db: appConfig.redisDb, + enableOfflineQueue: false, + enableReadyCheck: true, +}; + +if (appConfig.redisSentinelHost) { + redisConfig.sentinels = [ + { + host: appConfig.redisSentinelHost, + port: appConfig.redisSentinelPort, + } + ]; + + redisConfig.sentinelUsername = appConfig.redisSentinelUsername; + redisConfig.sentinelPassword = appConfig.redisSentinelPassword; + redisConfig.name = appConfig.redisName; + redisConfig.role = appConfig.redisRole; +} else { + redisConfig.host = appConfig.redisHost; + redisConfig.port = appConfig.redisPort; +} + +if (appConfig.redisTls) { + redisConfig.tls = {}; +} + +export default redisConfig; diff --git a/packages/backend/src/controllers/api/v1/access-tokens/create-access-token.js b/packages/backend/src/controllers/api/v1/access-tokens/create-access-token.js new file mode 100644 index 0000000..5fe162a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/access-tokens/create-access-token.js @@ -0,0 +1,13 @@ +import User from '../../../../models/user.js'; +import { renderObject, renderError } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const { email, password } = request.body; + const token = await User.authenticate(email, password); + + if (token) { + return renderObject(response, { token }); + } + + renderError(response, [{ general: ['Incorrect email or password.'] }]); +}; diff --git a/packages/backend/src/controllers/api/v1/access-tokens/create-access-token.test.js b/packages/backend/src/controllers/api/v1/access-tokens/create-access-token.test.js new file mode 100644 index 0000000..2232cb5 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/access-tokens/create-access-token.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import { createUser } from '../../../../../test/factories/user'; + +describe('POST /api/v1/access-tokens', () => { + beforeEach(async () => { + await createUser({ + email: 'user@automatisch.io', + password: 'password', + }); + }); + + it('should return the token data with correct credentials', async () => { + const response = await request(app) + .post('/api/v1/access-tokens') + .send({ + email: 'user@automatisch.io', + password: 'password', + }) + .expect(200); + + expect(response.body.data.token.length).toBeGreaterThan(0); + }); + + it('should return error with incorrect credentials', async () => { + const response = await request(app) + .post('/api/v1/access-tokens') + .send({ + email: 'incorrect@email.com', + password: 'incorrectpassword', + }) + .expect(422); + + expect(response.body.errors.general).toStrictEqual([ + 'Incorrect email or password.', + ]); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.js b/packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.js new file mode 100644 index 0000000..dd62c96 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.js @@ -0,0 +1,15 @@ +export default async (request, response) => { + const token = request.params.token; + + const accessToken = await request.currentUser + .$relatedQuery('accessTokens') + .findOne({ + token, + revoked_at: null, + }) + .throwIfNotFound(); + + await accessToken.revoke(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.test.js b/packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.test.js new file mode 100644 index 0000000..1651418 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/access-tokens/revoke-access-token.test.js @@ -0,0 +1,54 @@ +import { expect, describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user.js'; +import AccessToken from '../../../../models/access-token.js'; + +describe('DELETE /api/v1/access-tokens/:token', () => { + let token; + + beforeEach(async () => { + const currentUser = await createUser({ + email: 'user@automatisch.io', + password: 'password', + }); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should respond with HTTP 204 with correct token', async () => { + await request(app) + .delete(`/api/v1/access-tokens/${token}`) + .set('Authorization', token) + .expect(204); + + const revokedToken = await AccessToken.query().findOne({ token }); + + expect(revokedToken).toBeDefined(); + expect(revokedToken.revokedAt).not.toBeNull(); + }); + + it('should respond with HTTP 401 with incorrect credentials', async () => { + await request(app) + .delete(`/api/v1/access-tokens/${token}`) + .set('Authorization', 'wrong-token') + .expect(401); + + const unrevokedToken = await AccessToken.query().findOne({ token }); + + expect(unrevokedToken).toBeDefined(); + expect(unrevokedToken.revokedAt).toBeNull(); + }); + + it('should respond with HTTP 404 with correct credentials, but non-valid token', async () => { + await request(app) + .delete('/api/v1/access-tokens/wrong-token') + .set('Authorization', token) + .expect(404); + + const unrevokedToken = await AccessToken.query().findOne({ token }); + + expect(unrevokedToken).toBeDefined(); + expect(unrevokedToken.revokedAt).toBeNull(); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js new file mode 100644 index 0000000..5ae08ea --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js @@ -0,0 +1,20 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import AppConfig from '../../../../../models/app-config.js'; + +export default async (request, response) => { + const createdAppConfig = await AppConfig.query().insertAndFetch( + appConfigParams(request) + ); + + renderObject(response, createdAppConfig, { status: 201 }); +}; + +const appConfigParams = (request) => { + const { useOnlyPredefinedAuthClients, disabled } = request.body; + + return { + key: request.params.appKey, + useOnlyPredefinedAuthClients, + disabled, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js new file mode 100644 index 0000000..3ee2bab --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js @@ -0,0 +1,66 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import createAppConfigMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/create-config.js'; +import { createAppConfig } from '../../../../../../test/factories/app-config.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/apps/:appKey/config', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created app config', async () => { + const appConfig = { + useOnlyPredefinedAuthClients: false, + disabled: false, + }; + + const response = await request(app) + .post('/api/v1/admin/apps/gitlab/config') + .set('Authorization', token) + .send(appConfig) + .expect(201); + + const expectedPayload = createAppConfigMock({ + ...appConfig, + key: 'gitlab', + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return HTTP 422 for already existing app config', async () => { + const appConfig = { + key: 'gitlab', + useOnlyPredefinedAuthClients: false, + disabled: false, + }; + + await createAppConfig(appConfig); + + const response = await request(app) + .post('/api/v1/admin/apps/gitlab/config') + .set('Authorization', token) + .send({ + disabled: false, + }) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('UniqueViolationError'); + expect(response.body.errors).toMatchObject({ + key: ["'key' must be unique."], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js new file mode 100644 index 0000000..0120e2a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js @@ -0,0 +1,25 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import AppConfig from '../../../../../models/app-config.js'; + +export default async (request, response) => { + const appConfig = await AppConfig.query() + .findOne({ key: request.params.appKey }) + .throwIfNotFound(); + + const oauthClient = await appConfig.createOAuthClient( + oauthClientParams(request) + ); + + renderObject(response, oauthClient, { status: 201 }); +}; + +const oauthClientParams = (request) => { + const { active, appKey, name, formattedAuthDefaults } = request.body; + + return { + active, + appKey, + name, + formattedAuthDefaults, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js new file mode 100644 index 0000000..8f1eedd --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js @@ -0,0 +1,122 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import createOAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/create-oauth-client.js'; +import { createAppConfig } from '../../../../../../test/factories/app-config.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/apps/:appKey/oauth-clients', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created response for valid app config', async () => { + await createAppConfig({ + key: 'gitlab', + }); + + const oauthClient = { + active: true, + appKey: 'gitlab', + name: 'First auth client', + formattedAuthDefaults: { + clientid: 'sample client ID', + clientSecret: 'sample client secret', + instanceUrl: 'https://gitlab.com', + oAuthRedirectUrl: 'http://localhost:3001/app/gitlab/connection/add', + }, + }; + + const response = await request(app) + .post('/api/v1/admin/apps/gitlab/oauth-clients') + .set('Authorization', token) + .send(oauthClient) + .expect(201); + + const expectedPayload = createOAuthClientMock(oauthClient); + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should throw validation error for app that does not support oauth connections', async () => { + await createAppConfig({ + key: 'deepl', + }); + + const oauthClient = { + active: true, + appKey: 'deepl', + name: 'First auth client', + formattedAuthDefaults: { + clientid: 'sample client ID', + clientSecret: 'sample client secret', + instanceUrl: 'https://deepl.com', + oAuthRedirectUrl: 'http://localhost:3001/app/deepl/connection/add', + }, + }; + + const response = await request(app) + .post('/api/v1/admin/apps/deepl/oauth-clients') + .set('Authorization', token) + .send(oauthClient) + .expect(422); + + expect(response.body.errors).toMatchObject({ + app: ['This app does not support OAuth clients!'], + }); + }); + + it('should return not found response for not existing app config', async () => { + const oauthClient = { + active: true, + appKey: 'gitlab', + name: 'First auth client', + formattedAuthDefaults: { + clientid: 'sample client ID', + clientSecret: 'sample client secret', + instanceUrl: 'https://gitlab.com', + oAuthRedirectUrl: 'http://localhost:3001/app/gitlab/connection/add', + }, + }; + + await request(app) + .post('/api/v1/admin/apps/gitlab/oauth-clients') + .set('Authorization', token) + .send(oauthClient) + .expect(404); + }); + + it('should return bad request response for missing required fields', async () => { + await createAppConfig({ + key: 'gitlab', + }); + + const oauthClient = { + appKey: 'gitlab', + }; + + const response = await request(app) + .post('/api/v1/admin/apps/gitlab/oauth-clients') + .set('Authorization', token) + .send(oauthClient) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toMatchObject({ + name: ["must have required property 'name'"], + formattedAuthDefaults: [ + "must have required property 'formattedAuthDefaults'", + ], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js new file mode 100644 index 0000000..577461f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import OAuthClient from '../../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClient = await OAuthClient.query() + .findById(request.params.oauthClientId) + .where({ app_key: request.params.appKey }) + .throwIfNotFound(); + + renderObject(response, oauthClient); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js new file mode 100644 index 0000000..5b30c28 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js @@ -0,0 +1,55 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import getOAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/get-oauth-client.js'; +import { createOAuthClient } from '../../../../../../test/factories/oauth-client.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/apps/:appKey/oauth-clients/:oauthClientId', () => { + let currentUser, adminRole, currentOAuthClient, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + currentOAuthClient = await createOAuthClient({ + appKey: 'deepl', + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return specified oauth client', async () => { + const response = await request(app) + .get(`/api/v1/admin/apps/deepl/oauth-clients/${currentOAuthClient.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getOAuthClientMock(currentOAuthClient); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing oauth client ID', async () => { + const notExistingOAuthClientUUID = Crypto.randomUUID(); + + await request(app) + .get( + `/api/v1/admin/apps/deepl/oauth-clients/${notExistingOAuthClientUUID}` + ) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .get('/api/v1/admin/apps/deepl/oauth-clients/invalidOAuthClientUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js new file mode 100644 index 0000000..230104a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js @@ -0,0 +1,10 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import OAuthClient from '../../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClients = await OAuthClient.query() + .where({ app_key: request.params.appKey }) + .orderBy('created_at', 'desc'); + + renderObject(response, oauthClients); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js new file mode 100644 index 0000000..69be2bb --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js @@ -0,0 +1,44 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import getAdminOAuthClientsMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js'; +import { createOAuthClient } from '../../../../../../test/factories/oauth-client.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/apps/:appKey/oauth-clients', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return specified oauth client info', async () => { + const oauthClientOne = await createOAuthClient({ + appKey: 'deepl', + }); + + const oauthClientTwo = await createOAuthClient({ + appKey: 'deepl', + }); + + const response = await request(app) + .get('/api/v1/admin/apps/deepl/oauth-clients') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAdminOAuthClientsMock([ + oauthClientTwo, + oauthClientOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js new file mode 100644 index 0000000..c0d5160 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js @@ -0,0 +1,26 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import AppConfig from '../../../../../models/app-config.js'; + +export default async (request, response) => { + const appConfig = await AppConfig.query() + .findOne({ + key: request.params.appKey, + }) + .throwIfNotFound(); + + await appConfig.$query().patchAndFetch({ + ...appConfigParams(request), + key: request.params.appKey, + }); + + renderObject(response, appConfig); +}; + +const appConfigParams = (request) => { + const { useOnlyPredefinedAuthClients, disabled } = request.body; + + return { + useOnlyPredefinedAuthClients, + disabled, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js new file mode 100644 index 0000000..5894424 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js @@ -0,0 +1,87 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import createAppConfigMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/create-config.js'; +import { createAppConfig } from '../../../../../../test/factories/app-config.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/apps/:appKey/config', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated app config', async () => { + const appConfig = { + key: 'gitlab', + useOnlyPredefinedAuthClients: true, + disabled: false, + }; + + await createAppConfig(appConfig); + + const newAppConfigValues = { + disabled: true, + useOnlyPredefinedAuthClients: false, + }; + + const response = await request(app) + .patch('/api/v1/admin/apps/gitlab/config') + .set('Authorization', token) + .send(newAppConfigValues) + .expect(200); + + const expectedPayload = createAppConfigMock({ + ...newAppConfigValues, + key: 'gitlab', + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return not found response for unexisting app config', async () => { + const appConfig = { + disabled: true, + useOnlyPredefinedAuthClients: false, + }; + + await request(app) + .patch('/api/v1/admin/apps/gitlab/config') + .set('Authorization', token) + .send(appConfig) + .expect(404); + }); + + it('should return HTTP 422 for invalid app config data', async () => { + const appConfig = { + key: 'gitlab', + useOnlyPredefinedAuthClients: true, + disabled: false, + }; + + await createAppConfig(appConfig); + + const response = await request(app) + .patch('/api/v1/admin/apps/gitlab/config') + .set('Authorization', token) + .send({ + disabled: 'invalid value type', + }) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toMatchObject({ + disabled: ['must be boolean'], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js new file mode 100644 index 0000000..7e9c3f7 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js @@ -0,0 +1,22 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import OAuthClient from '../../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClient = await OAuthClient.query() + .findById(request.params.oauthClientId) + .throwIfNotFound(); + + await oauthClient.$query().patchAndFetch(oauthClientParams(request)); + + renderObject(response, oauthClient); +}; + +const oauthClientParams = (request) => { + const { active, name, formattedAuthDefaults } = request.body; + + return { + active, + name, + formattedAuthDefaults, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js new file mode 100644 index 0000000..9d28bb3 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js @@ -0,0 +1,104 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; + +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import updateOAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/update-oauth-client.js'; +import { createAppConfig } from '../../../../../../test/factories/app-config.js'; +import { createOAuthClient } from '../../../../../../test/factories/oauth-client.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/apps/:appKey/oauth-clients', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + + await createAppConfig({ + key: 'gitlab', + }); + }); + + it('should return updated entity for valid oauth client', async () => { + const oauthClient = { + active: true, + appKey: 'gitlab', + formattedAuthDefaults: { + clientid: 'sample client ID', + clientSecret: 'sample client secret', + instanceUrl: 'https://gitlab.com', + oAuthRedirectUrl: 'http://localhost:3001/app/gitlab/connection/add', + }, + }; + + const existingOAuthClient = await createOAuthClient({ + appKey: 'gitlab', + name: 'First auth client', + }); + + const response = await request(app) + .patch( + `/api/v1/admin/apps/gitlab/oauth-clients/${existingOAuthClient.id}` + ) + .set('Authorization', token) + .send(oauthClient) + .expect(200); + + const expectedPayload = updateOAuthClientMock({ + ...existingOAuthClient, + ...oauthClient, + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return not found response for not existing oauth client', async () => { + const notExistingOAuthClientId = Crypto.randomUUID(); + + await request(app) + .patch( + `/api/v1/admin/apps/gitlab/oauth-clients/${notExistingOAuthClientId}` + ) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .patch('/api/v1/admin/apps/gitlab/oauth-clients/invalidAuthClientUUID') + .set('Authorization', token) + .expect(400); + }); + + it('should return HTTP 422 for invalid payload', async () => { + const oauthClient = { + formattedAuthDefaults: 'invalid input', + }; + + const existingOAuthClient = await createOAuthClient({ + appKey: 'gitlab', + name: 'First auth client', + }); + + const response = await request(app) + .patch( + `/api/v1/admin/apps/gitlab/oauth-clients/${existingOAuthClient.id}` + ) + .set('Authorization', token) + .send(oauthClient) + .expect(422); + + expect(response.body.meta.type).toBe('ModelValidation'); + expect(response.body.errors).toMatchObject({ + formattedAuthDefaults: ['must be object'], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/config/update.ee.js b/packages/backend/src/controllers/api/v1/admin/config/update.ee.js new file mode 100644 index 0000000..50c76be --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/config/update.ee.js @@ -0,0 +1,28 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import Config from '../../../../../models/config.js'; + +export default async (request, response) => { + const config = await Config.query().updateFirstOrInsert( + configParams(request) + ); + + renderObject(response, config); +}; + +const configParams = (request) => { + const { + logoSvgData, + palettePrimaryDark, + palettePrimaryLight, + palettePrimaryMain, + title, + } = request.body; + + return { + logoSvgData, + palettePrimaryDark, + palettePrimaryLight, + palettePrimaryMain, + title, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js b/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js new file mode 100644 index 0000000..5984cdb --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/config/update.ee.test.js @@ -0,0 +1,88 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { updateConfig } from '../../../../../../test/factories/config.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/config', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated config', async () => { + const title = 'Test environment - Automatisch'; + const palettePrimaryMain = '#00adef'; + const palettePrimaryDark = '#222222'; + const palettePrimaryLight = '#f90707'; + const logoSvgData = + 'A'; + + const appConfig = { + title, + palettePrimaryMain: palettePrimaryMain, + palettePrimaryDark: palettePrimaryDark, + palettePrimaryLight: palettePrimaryLight, + logoSvgData: logoSvgData, + }; + + await updateConfig(appConfig); + + const newTitle = 'Updated title'; + + const newConfigValues = { + title: newTitle, + }; + + const response = await request(app) + .patch('/api/v1/admin/config') + .set('Authorization', token) + .send(newConfigValues) + .expect(200); + + expect(response.body.data.title).toStrictEqual(newTitle); + expect(response.body.meta.type).toStrictEqual('Config'); + }); + + it('should return created config for unexisting config', async () => { + const newTitle = 'Updated title'; + + const newConfigValues = { + title: newTitle, + }; + + const response = await request(app) + .patch('/api/v1/admin/config') + .set('Authorization', token) + .send(newConfigValues) + .expect(200); + + expect(response.body.data.title).toStrictEqual(newTitle); + expect(response.body.meta.type).toStrictEqual('Config'); + }); + + it('should return null for deleted config entry', async () => { + const newConfigValues = { + title: null, + }; + + const response = await request(app) + .patch('/api/v1/admin/config') + .set('Authorization', token) + .send(newConfigValues) + .expect(200); + + expect(response.body.data.title).toBeNull(); + expect(response.body.meta.type).toStrictEqual('Config'); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.js b/packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.js new file mode 100644 index 0000000..232e33e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.js @@ -0,0 +1,6 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import permissionCatalog from '../../../../../helpers/permission-catalog.ee.js'; + +export default async (request, response) => { + renderObject(response, permissionCatalog); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.test.js b/packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.test.js new file mode 100644 index 0000000..f491ba7 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/permissions/get-permissions-catalog.ee.test.js @@ -0,0 +1,32 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import getPermissionsCatalogMock from '../../../../../../test/mocks/rest/api/v1/admin/permissions/get-permissions-catalog.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/permissions/catalog', () => { + let role, currentUser, token; + + beforeEach(async () => { + role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return roles', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get('/api/v1/admin/permissions/catalog') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getPermissionsCatalogMock(); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.js b/packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.js new file mode 100644 index 0000000..124de64 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.js @@ -0,0 +1,22 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const roleData = roleParams(request); + + const roleWithPermissions = await Role.query().insertGraphAndFetch(roleData, { + relate: ['permissions'], + }); + + renderObject(response, roleWithPermissions, { status: 201 }); +}; + +const roleParams = (request) => { + const { name, description, permissions } = request.body; + + return { + name, + description, + permissions, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.test.js b/packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.test.js new file mode 100644 index 0000000..2d10fc8 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/create-role.ee.test.js @@ -0,0 +1,109 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import Role from '../../../../../models/role.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import createRoleMock from '../../../../../../test/mocks/rest/api/v1/admin/roles/create-role.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/roles', () => { + let role, currentUser, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the created role along with permissions', async () => { + const roleData = { + name: 'Viewer', + description: '', + permissions: [ + { + action: 'read', + subject: 'Flow', + conditions: ['isCreator'], + }, + ], + }; + + const response = await request(app) + .post('/api/v1/admin/roles') + .set('Authorization', token) + .send(roleData) + .expect(201); + + const createdRole = await Role.query() + .withGraphFetched({ permissions: true }) + .findOne({ name: 'Viewer' }) + .throwIfNotFound(); + + const expectedPayload = await createRoleMock( + { + ...createdRole, + ...roleData, + isAdmin: createdRole.isAdmin, + }, + [ + { + ...createdRole.permissions[0], + ...roleData.permissions[0], + }, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return unprocessable entity response for invalid role data', async () => { + const roleData = { + description: '', + permissions: [], + }; + + const response = await request(app) + .post('/api/v1/admin/roles') + .set('Authorization', token) + .send(roleData) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + name: ["must have required property 'name'"], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + + it('should return unprocessable entity response for duplicate role', async () => { + await createRole({ name: 'Viewer' }); + + const roleData = { + name: 'Viewer', + permissions: [], + }; + + const response = await request(app) + .post('/api/v1/admin/roles') + .set('Authorization', token) + .send(roleData) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + name: ["'name' must be unique."], + }, + meta: { + type: 'UniqueViolationError', + }, + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.js b/packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.js new file mode 100644 index 0000000..99eb00f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.js @@ -0,0 +1,11 @@ +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const role = await Role.query() + .findById(request.params.roleId) + .throwIfNotFound(); + + await role.deleteWithPermissions(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.test.js b/packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.test.js new file mode 100644 index 0000000..839acc1 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/delete-role.ee.test.js @@ -0,0 +1,95 @@ +import Crypto from 'node:crypto'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createPermission } from '../../../../../../test/factories/permission.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('DELETE /api/v1/admin/roles/:roleId', () => { + let adminRole, currentUser, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return HTTP 204 for unused role', async () => { + const role = await createRole(); + const permission = await createPermission({ roleId: role.id }); + + await request(app) + .delete(`/api/v1/admin/roles/${role.id}`) + .set('Authorization', token) + .expect(204); + + const refetchedRole = await role.$query(); + const refetchedPermission = await permission.$query(); + + expect(refetchedRole).toBeUndefined(); + expect(refetchedPermission).toBeUndefined(); + }); + + it('should return HTTP 404 for not existing role UUID', async () => { + const notExistingRoleUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/admin/roles/${notExistingRoleUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not authorized response for deleting admin role', async () => { + await request(app) + .delete(`/api/v1/admin/roles/${adminRole.id}`) + .set('Authorization', token) + .expect(403); + }); + + it('should return unprocessable entity response for role used by users', async () => { + const role = await createRole(); + await createUser({ roleId: role.id }); + + const response = await request(app) + .delete(`/api/v1/admin/roles/${role.id}`) + .set('Authorization', token) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + role: [`All users must be migrated away from the "${role.name}" role.`], + }, + meta: { + type: 'ValidationError', + }, + }); + }); + + it('should return unprocessable entity response for role used by saml auth providers', async () => { + const samlAuthProvider = await createSamlAuthProvider(); + + const response = await request(app) + .delete(`/api/v1/admin/roles/${samlAuthProvider.defaultRoleId}`) + .set('Authorization', token) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + samlAuthProvider: [ + 'You need to change the default role in the SAML configuration before deleting this role.', + ], + }, + meta: { + type: 'ValidationError', + }, + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.js b/packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.js new file mode 100644 index 0000000..57733ae --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.js @@ -0,0 +1,16 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const role = await Role.query() + .leftJoinRelated({ + permissions: true, + }) + .withGraphFetched({ + permissions: true, + }) + .findById(request.params.roleId) + .throwIfNotFound(); + + renderObject(response, role); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.test.js b/packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.test.js new file mode 100644 index 0000000..1a51fb3 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/get-role.ee.test.js @@ -0,0 +1,59 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createPermission } from '../../../../../../test/factories/permission.js'; +import getRoleMock from '../../../../../../test/mocks/rest/api/v1/admin/roles/get-role.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/roles/:roleId', () => { + let role, currentUser, token, permissionOne, permissionTwo; + + beforeEach(async () => { + role = await createRole({ name: 'Admin' }); + permissionOne = await createPermission({ roleId: role.id }); + permissionTwo = await createPermission({ roleId: role.id }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return role', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get(`/api/v1/admin/roles/${role.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getRoleMock(role, [ + permissionOne, + permissionTwo, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing role UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const notExistingRoleUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/admin/roles/${notExistingRoleUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + await request(app) + .get('/api/v1/admin/roles/invalidRoleUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.js b/packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.js new file mode 100644 index 0000000..3193b3e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.js @@ -0,0 +1,8 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const roles = await Role.query().orderBy('name'); + + renderObject(response, roles); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.test.js b/packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.test.js new file mode 100644 index 0000000..facee6d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/get-roles.ee.test.js @@ -0,0 +1,33 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import getRolesMock from '../../../../../../test/mocks/rest/api/v1/admin/roles/get-roles.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/roles', () => { + let roleOne, roleTwo, currentUser, token; + + beforeEach(async () => { + roleOne = await createRole({ name: 'Admin' }); + roleTwo = await createRole({ name: 'User' }); + currentUser = await createUser({ roleId: roleOne.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return roles', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get('/api/v1/admin/roles') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getRolesMock([roleOne, roleTwo]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.js b/packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.js new file mode 100644 index 0000000..de8a9aa --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.js @@ -0,0 +1,24 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const role = await Role.query() + .findById(request.params.roleId) + .throwIfNotFound(); + + const updatedRoleWithPermissions = await role.updateWithPermissions( + roleParams(request) + ); + + renderObject(response, updatedRoleWithPermissions); +}; + +const roleParams = (request) => { + const { name, description, permissions } = request.body; + + return { + name, + description, + permissions, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.test.js b/packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.test.js new file mode 100644 index 0000000..8d6a363 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/roles/update-role.ee.test.js @@ -0,0 +1,177 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createPermission } from '../../../../../../test/factories/permission.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import updateRoleMock from '../../../../../../test/mocks/rest/api/v1/admin/roles/update-role.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/roles/:roleId', () => { + let adminRole, viewerRole, currentUser, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + adminRole = await createRole({ name: 'Admin' }); + viewerRole = await createRole({ name: 'Viewer' }); + + await createPermission({ + action: 'read', + subject: 'Connection', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + }); + + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the updated role along with permissions', async () => { + const roleData = { + name: 'Updated role name', + description: 'A new description', + permissions: [ + { + action: 'read', + subject: 'Execution', + conditions: ['isCreator'], + }, + ], + }; + + const response = await request(app) + .patch(`/api/v1/admin/roles/${viewerRole.id}`) + .set('Authorization', token) + .send(roleData) + .expect(200); + + const refetchedViewerRole = await viewerRole + .$query() + .withGraphFetched({ permissions: true }); + + const expectedPayload = await updateRoleMock( + { + ...refetchedViewerRole, + ...roleData, + isAdmin: false, + }, + [ + { + ...refetchedViewerRole.permissions[0], + ...roleData.permissions[0], + }, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the updated role with sanitized permissions', async () => { + const validPermission = { + action: 'create', + subject: 'Connection', + conditions: ['isCreator'], + }; + + const invalidPermission = { + action: 'publish', + subject: 'Connection', + conditions: ['isCreator'], + }; + + const roleData = { + permissions: [validPermission, invalidPermission], + }; + + const response = await request(app) + .patch(`/api/v1/admin/roles/${viewerRole.id}`) + .set('Authorization', token) + .send(roleData) + .expect(200); + + const refetchedViewerRole = await viewerRole.$query().withGraphFetched({ + permissions: true, + }); + + const expectedPayload = updateRoleMock(refetchedViewerRole, [ + { + ...refetchedViewerRole.permissions[0], + ...validPermission, + }, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not authorized response for updating admin role', async () => { + const roleData = { + name: 'Updated role name', + description: 'A new description', + permissions: [ + { + action: 'read', + subject: 'Execution', + conditions: ['isCreator'], + }, + ], + }; + + await request(app) + .patch(`/api/v1/admin/roles/${adminRole.id}`) + .set('Authorization', token) + .send(roleData) + .expect(403); + }); + + it('should return unprocessable entity response for invalid role data', async () => { + const roleData = { + description: 123, + permissions: [], + }; + + const response = await request(app) + .patch(`/api/v1/admin/roles/${viewerRole.id}`) + .set('Authorization', token) + .send(roleData) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + description: ['must be string,null'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + + it('should return unique violation response for duplicate role data', async () => { + await createRole({ name: 'Editor' }); + + const roleData = { + name: 'Editor', + permissions: [], + }; + + const response = await request(app) + .patch(`/api/v1/admin/roles/${viewerRole.id}`) + .set('Authorization', token) + .send(roleData) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + name: ["'name' must be unique."], + }, + meta: { + type: 'UniqueViolationError', + }, + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js new file mode 100644 index 0000000..0cddadf --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js @@ -0,0 +1,43 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProvider = await SamlAuthProvider.query().insert( + samlAuthProviderParams(request) + ); + + renderObject(response, samlAuthProvider, { + serializer: 'AdminSamlAuthProvider', + status: 201, + }); +}; + +const samlAuthProviderParams = (request) => { + const { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + } = request.body; + + return { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js new file mode 100644 index 0000000..eff660d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.test.js @@ -0,0 +1,78 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import createSamlAuthProviderMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/saml-auth-provider', () => { + let currentUser, token, role; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the created saml auth provider', async () => { + const samlAuthProviderPayload = { + active: true, + name: 'Name', + issuer: 'theclientid', + certificate: 'dummycert', + entryPoint: 'http://localhost:8080/realms/automatisch/protocol/saml', + signatureAlgorithm: 'sha256', + defaultRoleId: role.id, + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 'Role', + }; + + const response = await request(app) + .post('/api/v1/admin/saml-auth-providers') + .set('Authorization', token) + .send(samlAuthProviderPayload) + .expect(201); + + const expectedPayload = await createSamlAuthProviderMock({ + id: response.body.data.id, + ...samlAuthProviderPayload, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const response = await request(app) + .post('/api/v1/admin/saml-auth-providers') + .set('Authorization', token) + .send({ + active: true, + name: 'Name', + issuer: 'theclientid', + signatureAlgorithm: 'invalid', + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + certificate: ["must have required property 'certificate'"], + entryPoint: ["must have required property 'entryPoint'"], + defaultRoleId: ["must have required property 'defaultRoleId'"], + signatureAlgorithm: ['must be equal to one of the allowed values'], + roleAttributeName: ['must be string'], + }, + meta: { type: 'ModelValidation' }, + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js new file mode 100644 index 0000000..9c7f5ed --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProvider = await SamlAuthProvider.query() + .findById(request.params.samlAuthProviderId) + .throwIfNotFound(); + + const roleMappings = await samlAuthProvider + .$relatedQuery('roleMappings') + .orderBy('remote_role_name', 'asc'); + + renderObject(response, roleMappings); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.test.js new file mode 100644 index 0000000..fdac348 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.test.js @@ -0,0 +1,51 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import { createRoleMapping } from '../../../../../../test/factories/role-mapping.js'; +import getRoleMappingsMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappings', () => { + let roleMappingOne, roleMappingTwo, samlAuthProvider, currentUser, token; + + beforeEach(async () => { + const role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + samlAuthProvider = await createSamlAuthProvider(); + + roleMappingOne = await createRoleMapping({ + samlAuthProviderId: samlAuthProvider.id, + remoteRoleName: 'Admin', + }); + + roleMappingTwo = await createRoleMapping({ + samlAuthProviderId: samlAuthProvider.id, + remoteRoleName: 'User', + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return role mappings', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get( + `/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings` + ) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getRoleMappingsMock([ + roleMappingOne, + roleMappingTwo, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js new file mode 100644 index 0000000..1fdf633 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js @@ -0,0 +1,12 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProvider = await SamlAuthProvider.query() + .findById(request.params.samlAuthProviderId) + .throwIfNotFound(); + + renderObject(response, samlAuthProvider, { + serializer: 'AdminSamlAuthProvider', + }); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.test.js new file mode 100644 index 0000000..474dc0b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.test.js @@ -0,0 +1,57 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import getSamlAuthProviderMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/saml-auth-provider/:samlAuthProviderId', () => { + let samlAuthProvider, currentUser, token; + + beforeEach(async () => { + const role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + samlAuthProvider = await createSamlAuthProvider(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return saml auth provider with specified id', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get(`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getSamlAuthProviderMock(samlAuthProvider); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing saml auth provider UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const notExistingSamlAuthProviderUUID = Crypto.randomUUID(); + + await request(app) + .get( + `/api/v1/admin/saml-auth-providers/${notExistingSamlAuthProviderUUID}` + ) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + await request(app) + .get('/api/v1/admin/saml-auth-providers/invalidSamlAuthProviderUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js new file mode 100644 index 0000000..88b3d63 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js @@ -0,0 +1,13 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProviders = await SamlAuthProvider.query().orderBy( + 'created_at', + 'desc' + ); + + renderObject(response, samlAuthProviders, { + serializer: 'AdminSamlAuthProvider', + }); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.test.js new file mode 100644 index 0000000..7965d4a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.test.js @@ -0,0 +1,39 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import getSamlAuthProvidersMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/saml-auth-providers', () => { + let samlAuthProviderOne, samlAuthProviderTwo, currentUser, token; + + beforeEach(async () => { + const role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + samlAuthProviderOne = await createSamlAuthProvider(); + samlAuthProviderTwo = await createSamlAuthProvider(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return saml auth providers', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get('/api/v1/admin/saml-auth-providers') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getSamlAuthProvidersMock([ + samlAuthProviderTwo, + samlAuthProviderOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js new file mode 100644 index 0000000..9ca388c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js @@ -0,0 +1,25 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProviderId = request.params.samlAuthProviderId; + + const samlAuthProvider = await SamlAuthProvider.query() + .findById(samlAuthProviderId) + .throwIfNotFound(); + + const roleMappings = await samlAuthProvider.updateRoleMappings( + roleMappingsParams(request) + ); + + renderObject(response, roleMappings); +}; + +const roleMappingsParams = (request) => { + const roleMappings = request.body; + + return roleMappings.map(({ roleId, remoteRoleName }) => ({ + roleId, + remoteRoleName, + })); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.test.js new file mode 100644 index 0000000..a3c12c0 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.test.js @@ -0,0 +1,152 @@ +import Crypto from 'node:crypto'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import { createRoleMapping } from '../../../../../../test/factories/role-mapping.js'; +import createRoleMappingsMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappings', () => { + let samlAuthProvider, currentUser, userRole, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + userRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: userRole.id }); + + samlAuthProvider = await createSamlAuthProvider(); + + await createRoleMapping({ + samlAuthProviderId: samlAuthProvider.id, + remoteRoleName: 'Viewer', + }); + + await createRoleMapping({ + samlAuthProviderId: samlAuthProvider.id, + remoteRoleName: 'Editor', + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should update role mappings', async () => { + const roleMappings = [ + { + roleId: userRole.id, + remoteRoleName: 'Admin', + }, + ]; + + const response = await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings` + ) + .set('Authorization', token) + .send(roleMappings) + .expect(200); + + const expectedPayload = await createRoleMappingsMock([ + { + roleId: userRole.id, + remoteRoleName: 'Admin', + id: response.body.data[0].id, + samlAuthProviderId: samlAuthProvider.id, + }, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should delete role mappings when given empty role mappings', async () => { + const existingRoleMappings = await samlAuthProvider.$relatedQuery( + 'roleMappings' + ); + + expect(existingRoleMappings.length).toBe(2); + + const response = await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings` + ) + .set('Authorization', token) + .send([]) + .expect(200); + + const expectedPayload = await createRoleMappingsMock([]); + + expect(response.body).toStrictEqual({ + ...expectedPayload, + meta: { + ...expectedPayload.meta, + type: 'Object', + }, + }); + }); + + it('should return internal server error response for not existing role UUID', async () => { + const notExistingRoleUUID = Crypto.randomUUID(); + const roleMappings = [ + { + roleId: notExistingRoleUUID, + remoteRoleName: 'Admin', + }, + ]; + + await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings` + ) + .set('Authorization', token) + .send(roleMappings) + .expect(500); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const roleMappings = [ + { + roleId: userRole.id, + remoteRoleName: {}, + }, + ]; + + const response = await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings` + ) + .set('Authorization', token) + .send(roleMappings) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + remoteRoleName: ['must be string'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + + it('should return not found response for not existing SAML auth provider UUID', async () => { + const notExistingSamlAuthProviderUUID = Crypto.randomUUID(); + const roleMappings = [ + { + roleId: userRole.id, + remoteRoleName: 'Admin', + }, + ]; + + await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${notExistingSamlAuthProviderUUID}/role-mappings` + ) + .set('Authorization', token) + .send(roleMappings) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js new file mode 100644 index 0000000..bf678e9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js @@ -0,0 +1,45 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProvider = await SamlAuthProvider.query() + .patchAndFetchById( + request.params.samlAuthProviderId, + samlAuthProviderParams(request) + ) + .throwIfNotFound(); + + renderObject(response, samlAuthProvider, { + serializer: 'AdminSamlAuthProvider', + }); +}; + +const samlAuthProviderParams = (request) => { + const { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + } = request.body; + + return { + name, + certificate, + signatureAlgorithm, + issuer, + entryPoint, + firstnameAttributeName, + surnameAttributeName, + emailAttributeName, + roleAttributeName, + defaultRoleId, + active, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js new file mode 100644 index 0000000..d3fa960 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.test.js @@ -0,0 +1,119 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js'; +import createSamlAuthProviderMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('PATCH /api/v1/admin/saml-auth-provider/:samlAuthProviderId', () => { + let currentUser, token, role; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the updated saml auth provider', async () => { + const samlAuthProviderPayload = { + active: true, + name: 'Name', + issuer: 'theclientid', + certificate: 'dummycert', + entryPoint: 'http://localhost:8080/realms/automatisch/protocol/saml', + signatureAlgorithm: 'sha256', + defaultRoleId: role.id, + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 'Role', + }; + + const samlAuthProvider = await createSamlAuthProvider( + samlAuthProviderPayload + ); + + const response = await request(app) + .patch(`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}`) + .set('Authorization', token) + .send({ + active: false, + name: 'Archived', + }) + .expect(200); + + const refetchedSamlAuthProvider = await samlAuthProvider.$query(); + + const expectedPayload = await createSamlAuthProviderMock({ + ...refetchedSamlAuthProvider, + name: 'Archived', + active: false, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const samlAuthProviderPayload = { + active: true, + name: 'Name', + issuer: 'theclientid', + certificate: 'dummycert', + entryPoint: 'http://localhost:8080/realms/automatisch/protocol/saml', + signatureAlgorithm: 'sha256', + defaultRoleId: role.id, + firstnameAttributeName: 'urn:oid:2.5.4.42', + surnameAttributeName: 'urn:oid:2.5.4.4', + emailAttributeName: 'urn:oid:1.2.840.113549.1.9.1', + roleAttributeName: 'Role', + }; + + const samlAuthProvider = await createSamlAuthProvider( + samlAuthProviderPayload + ); + + const response = await request(app) + .patch(`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}`) + .set('Authorization', token) + .send({ + active: 'true', + name: 123, + roleAttributeName: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + name: ['must be string'], + active: ['must be boolean'], + roleAttributeName: ['must be string'], + }, + meta: { type: 'ModelValidation' }, + }); + }); + + it('should return not found response for not existing SAML auth provider UUID', async () => { + const notExistingSamlAuthProviderUUID = Crypto.randomUUID(); + + await request(app) + .patch( + `/api/v1/admin/saml-auth-providers/${notExistingSamlAuthProviderUUID}` + ) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .patch('/api/v1/admin/saml-auth-providers/invalidSamlAuthProviderUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/users/create-user.js b/packages/backend/src/controllers/api/v1/admin/users/create-user.js new file mode 100644 index 0000000..07951be --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/create-user.js @@ -0,0 +1,22 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import User from '../../../../../models/user.js'; +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const user = await User.query().insertAndFetch(await userParams(request)); + await user.sendInvitationEmail(); + + renderObject(response, user, { status: 201, serializer: 'AdminUser' }); +}; + +const userParams = async (request) => { + const { fullName, email } = request.body; + const roleId = request.body.roleId || (await Role.findAdmin()).id; + + return { + fullName, + status: 'invited', + email: email?.toLowerCase(), + roleId, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js b/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js new file mode 100644 index 0000000..b199eef --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js @@ -0,0 +1,122 @@ +import { describe, beforeEach, it, expect } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import User from '../../../../../models/user.js'; +import Role from '../../../../../models/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import createUserMock from '../../../../../../test/mocks/rest/api/v1/admin/users/create-user.js'; + +describe('POST /api/v1/admin/users', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created user with valid data', async () => { + const userRole = await createRole({ name: 'User' }); + + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + roleId: userRole.id, + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const expectedPayload = createUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedRegisteredUser.roleId).toStrictEqual(userRole.id); + }); + + it('should create user with admin role if there is no role id given', async () => { + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const refetchedUserRole = await Role.query().findById( + refetchedRegisteredUser.roleId + ); + + const expectedPayload = createUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedUserRole.name).toStrictEqual('Admin'); + }); + + it('should return unprocessable entity response with already used email', async () => { + await createRole({ name: 'User' }); + + await createUser({ + email: 'created@sample.com', + }); + + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + email: ["'email' must be unique."], + }); + + expect(response.body.meta).toStrictEqual({ + type: 'UniqueViolationError', + }); + }); + + it('should return unprocessable entity response with invalid user data', async () => { + await createRole({ name: 'User' }); + + const userData = { + email: null, + fullName: null, + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toStrictEqual({ + email: ["must have required property 'email'"], + fullName: ['must be string'], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/users/delete-user.js b/packages/backend/src/controllers/api/v1/admin/users/delete-user.js new file mode 100644 index 0000000..79fd430 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/delete-user.js @@ -0,0 +1,10 @@ +import User from '../../../../../models/user.js'; + +export default async (request, response) => { + const id = request.params.userId; + + const user = await User.query().findById(id).throwIfNotFound(); + await user.softRemove(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js b/packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js new file mode 100644 index 0000000..9ef9f05 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/delete-user.test.js @@ -0,0 +1,43 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../../test/factories/user'; +import { createRole } from '../../../../../../test/factories/role'; + +describe('DELETE /api/v1/admin/users/:userId', () => { + let currentUser, currentUserRole, anotherUser, token; + + beforeEach(async () => { + currentUserRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: currentUserRole.id }); + + anotherUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should soft delete user and respond with no content', async () => { + await request(app) + .delete(`/api/v1/admin/users/${anotherUser.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should return not found response for not existing user UUID', async () => { + const notExistingUserUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/admin/users/${notExistingUserUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .delete('/api/v1/admin/users/invalidUserUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/users/get-user.ee.js b/packages/backend/src/controllers/api/v1/admin/users/get-user.ee.js new file mode 100644 index 0000000..7c63e60 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/get-user.ee.js @@ -0,0 +1,13 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import User from '../../../../../models/user.js'; + +export default async (request, response) => { + const user = await User.query() + .withGraphFetched({ + role: true, + }) + .findById(request.params.userId) + .throwIfNotFound(); + + renderObject(response, user); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/get-user.ee.test.js b/packages/backend/src/controllers/api/v1/admin/users/get-user.ee.test.js new file mode 100644 index 0000000..2724ea9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/get-user.ee.test.js @@ -0,0 +1,55 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../../test/factories/user'; +import { createRole } from '../../../../../../test/factories/role'; +import getUserMock from '../../../../../../test/mocks/rest/api/v1/admin/users/get-user.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('GET /api/v1/admin/users/:userId', () => { + let currentUser, currentUserRole, anotherUser, anotherUserRole, token; + + beforeEach(async () => { + currentUserRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: currentUserRole.id }); + + anotherUser = await createUser(); + anotherUserRole = await anotherUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return specified user info', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get(`/api/v1/admin/users/${anotherUser.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getUserMock(anotherUser, anotherUserRole); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing user UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const notExistingUserUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/admin/users/${notExistingUserUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + await request(app) + .get('/api/v1/admin/users/invalidUserUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/users/get-users.ee.js b/packages/backend/src/controllers/api/v1/admin/users/get-users.ee.js new file mode 100644 index 0000000..1115404 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/get-users.ee.js @@ -0,0 +1,15 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import User from '../../../../../models/user.js'; +import paginateRest from '../../../../../helpers/pagination-rest.js'; + +export default async (request, response) => { + const usersQuery = User.query() + .withGraphFetched({ + role: true, + }) + .orderBy('full_name', 'asc'); + + const users = await paginateRest(usersQuery, request.query.page); + + renderObject(response, users); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/get-users.ee.test.js b/packages/backend/src/controllers/api/v1/admin/users/get-users.ee.test.js new file mode 100644 index 0000000..7b3ea36 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/get-users.ee.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id'; +import { createRole } from '../../../../../../test/factories/role'; +import { createUser } from '../../../../../../test/factories/user'; +import getUsersMock from '../../../../../../test/mocks/rest/api/v1/admin/users/get-users.js'; + +describe('GET /api/v1/admin/users', () => { + let currentUser, currentUserRole, anotherUser, anotherUserRole, token; + + beforeEach(async () => { + currentUserRole = await createRole({ name: 'Admin' }); + + currentUser = await createUser({ + roleId: currentUserRole.id, + fullName: 'Current User', + }); + + anotherUserRole = await createRole({ + name: 'Another user role', + }); + + anotherUser = await createUser({ + roleId: anotherUserRole.id, + fullName: 'Another User', + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return users data', async () => { + const response = await request(app) + .get('/api/v1/admin/users') + .set('Authorization', token) + .expect(200); + + const expectedResponsePayload = await getUsersMock( + [anotherUser, currentUser], + [anotherUserRole, currentUserRole] + ); + + expect(response.body).toStrictEqual(expectedResponsePayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/admin/users/update-user.ee.js b/packages/backend/src/controllers/api/v1/admin/users/update-user.ee.js new file mode 100644 index 0000000..efd1978 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/update-user.ee.js @@ -0,0 +1,18 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import User from '../../../../../models/user.js'; + +export default async (request, response) => { + const user = await User.query() + .withGraphFetched({ + role: true, + }) + .patchAndFetchById(request.params.userId, userParams(request)) + .throwIfNotFound(); + + renderObject(response, user); +}; + +const userParams = (request) => { + const { email, fullName, roleId } = request.body; + return { email, fullName, roleId }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/update-user.ee.test.js b/packages/backend/src/controllers/api/v1/admin/users/update-user.ee.test.js new file mode 100644 index 0000000..8700dd1 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/update-user.ee.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import updateUserMock from '../../../../../../test/mocks/rest/api/v1/admin/users/update-user.js'; + +describe('PATCH /api/v1/admin/users/:userId', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated user with valid data for another user', async () => { + const anotherUser = await createUser(); + const anotherRole = await createRole(); + + const anotherUserUpdatedData = { + email: 'updated@sample.com', + fullName: 'Updated Full Name', + roleId: anotherRole.id, + }; + + const response = await request(app) + .patch(`/api/v1/admin/users/${anotherUser.id}`) + .set('Authorization', token) + .send(anotherUserUpdatedData) + .expect(200); + + const refetchedAnotherUser = await anotherUser.$query(); + + const expectedPayload = updateUserMock( + { + ...refetchedAnotherUser, + ...anotherUserUpdatedData, + }, + anotherRole + ); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return HTTP 422 with invalid user data', async () => { + const anotherUser = await createUser(); + + const anotherUserUpdatedData = { + email: null, + fullName: null, + roleId: null, + }; + + const response = await request(app) + .patch(`/api/v1/admin/users/${anotherUser.id}`) + .set('Authorization', token) + .send(anotherUserUpdatedData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + + expect(response.body.errors).toMatchObject({ + email: ['must be string'], + fullName: ['must be string'], + roleId: ['must be string'], + }); + }); + + it('should return not found response for not existing user UUID', async () => { + const notExistingUserUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/admin/users/${notExistingUserUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .patch('/api/v1/admin/users/invalidUserUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/create-connection.js b/packages/backend/src/controllers/api/v1/apps/create-connection.js new file mode 100644 index 0000000..35e3a34 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/create-connection.js @@ -0,0 +1,27 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const connection = await request.currentUser + .$relatedQuery('connections') + .insertAndFetch(connectionParams(request)); + + const connectionWithAppConfigAndAuthClient = await connection + .$query() + .withGraphFetched({ + appConfig: true, + oauthClient: true, + }); + + renderObject(response, connectionWithAppConfigAndAuthClient, { status: 201 }); +}; + +const connectionParams = (request) => { + const { oauthClientId, formattedData } = request.body; + + return { + key: request.params.appKey, + oauthClientId, + formattedData, + verified: false, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/apps/create-connection.test.js b/packages/backend/src/controllers/api/v1/apps/create-connection.test.js new file mode 100644 index 0000000..0465458 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/create-connection.test.js @@ -0,0 +1,394 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createAppConfig } from '../../../../../test/factories/app-config.js'; +import { createOAuthClient } from '../../../../../test/factories/oauth-client.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import { createRole } from '../../../../../test/factories/role.js'; +import createConnection from '../../../../../test/mocks/rest/api/v1/apps/create-connection.js'; + +describe('POST /api/v1/apps/:appKey/connections', () => { + let currentUser, token; + + beforeEach(async () => { + const role = await createRole(); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: role.id, + }); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: role.id, + }); + + currentUser = await createUser({ roleId: role.id }); + + currentUser = await currentUser + .$query() + .leftJoinRelated({ + role: true, + permissions: true, + }) + .withGraphFetched({ + role: true, + permissions: true, + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + describe('with no app config', async () => { + it('should return created connection', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(201); + + const fetchedConnection = + await currentUser.authorizedConnections.findById(response.body.data.id); + + const expectedPayload = createConnection({ + ...fetchedConnection, + formattedData: {}, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with app disabled', async () => { + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: true, + }); + }); + + it('should return with not authorized response', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(403); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with custom connections enabled', async () => { + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + useOnlyPredefinedAuthClients: false, + }); + }); + + it('should return created conncetion', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(201); + + const fetchedConnection = + await currentUser.authorizedConnections.findById(response.body.data.id); + + const expectedPayload = createConnection({ + ...fetchedConnection, + formattedData: {}, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with custom connections disabled', async () => { + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + useOnlyPredefinedAuthClients: true, + }); + }); + + it('should return with not authorized response', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(403); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with auth client enabled', async () => { + let oauthClient; + + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + useOnlyPredefinedAuthClients: false, + }); + + oauthClient = await createOAuthClient({ + appKey: 'gitlab', + active: true, + formattedAuthDefaults: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }); + }); + + it('should return created connection', async () => { + const connectionData = { + oauthClientId: oauthClient.id, + }; + + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(201); + + const fetchedConnection = + await currentUser.authorizedConnections.findById(response.body.data.id); + + const expectedPayload = createConnection({ + ...fetchedConnection, + formattedData: {}, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with auth client disabled', async () => { + let oauthClient; + + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + useOnlyPredefinedAuthClients: false, + }); + + oauthClient = await createOAuthClient({ + appKey: 'gitlab', + active: false, + }); + }); + + it('should return with not authorized response', async () => { + const connectionData = { + oauthClientId: oauthClient.id, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(404); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-action-substeps.js b/packages/backend/src/controllers/api/v1/apps/get-action-substeps.js new file mode 100644 index 0000000..6b985bd --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-action-substeps.js @@ -0,0 +1,11 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const substeps = await App.findActionSubsteps( + request.params.appKey, + request.params.actionKey + ); + + renderObject(response, substeps); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js b/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js new file mode 100644 index 0000000..e3b6db0 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getActionSubstepsMock from '../../../../../test/mocks/rest/api/v1/apps/get-action-substeps.js'; + +describe('GET /api/v1/apps/:appKey/actions/:actionKey/substeps', () => { + let currentUser, exampleApp, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + exampleApp = await App.findOneByKey('github'); + }); + + it('should return the action substeps info', async () => { + const actions = await App.findActionsByKey('github'); + const exampleAction = actions.find( + (action) => action.key === 'createIssue' + ); + + const endpointUrl = `/api/v1/apps/${exampleApp.key}/actions/${exampleAction.key}/substeps`; + + const response = await request(app) + .get(endpointUrl) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getActionSubstepsMock(exampleAction.substeps); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .get('/api/v1/apps/invalid-app-key/actions/invalid-actions-key/substeps') + .set('Authorization', token) + .expect(404); + }); + + it('should return empty array for invalid action key', async () => { + const endpointUrl = `/api/v1/apps/${exampleApp.key}/actions/invalid-action-key/substeps`; + + const response = await request(app) + .get(endpointUrl) + .set('Authorization', token) + .expect(200); + + expect(response.body.data).toStrictEqual([]); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-actions.js b/packages/backend/src/controllers/api/v1/apps/get-actions.js new file mode 100644 index 0000000..78d45fd --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-actions.js @@ -0,0 +1,8 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const actions = await App.findActionsByKey(request.params.appKey); + + renderObject(response, actions, { serializer: 'Action' }); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-actions.test.js b/packages/backend/src/controllers/api/v1/apps/get-actions.test.js new file mode 100644 index 0000000..b2bf638 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-actions.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getActionsMock from '../../../../../test/mocks/rest/api/v1/apps/get-actions.js'; + +describe('GET /api/v1/apps/:appKey/actions', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the app actions', async () => { + const exampleApp = await App.findOneByKey('github'); + + const response = await request(app) + .get(`/api/v1/apps/${exampleApp.key}/actions`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getActionsMock(exampleApp.actions); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .get('/api/v1/apps/invalid-app-key/actions') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-app.js b/packages/backend/src/controllers/api/v1/apps/get-app.js new file mode 100644 index 0000000..2f27d3c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-app.js @@ -0,0 +1,8 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const app = await App.findOneByKey(request.params.appKey); + + renderObject(response, app, { serializer: 'App' }); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-app.test.js b/packages/backend/src/controllers/api/v1/apps/get-app.test.js new file mode 100644 index 0000000..321064d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-app.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getAppMock from '../../../../../test/mocks/rest/api/v1/apps/get-app.js'; + +describe('GET /api/v1/apps/:appKey', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the app info', async () => { + const exampleApp = await App.findOneByKey('github'); + + const response = await request(app) + .get(`/api/v1/apps/${exampleApp.key}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppMock(exampleApp); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .get('/api/v1/apps/invalid-app-key') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-apps.js b/packages/backend/src/controllers/api/v1/apps/get-apps.js new file mode 100644 index 0000000..be6e112 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-apps.js @@ -0,0 +1,16 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let apps = await App.findAll(request.query.name); + + if (request.query.onlyWithTriggers) { + apps = apps.filter((app) => app.triggers?.length); + } + + if (request.query.onlyWithActions) { + apps = apps.filter((app) => app.actions?.length); + } + + renderObject(response, apps, { serializer: 'App' }); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-apps.test.js b/packages/backend/src/controllers/api/v1/apps/get-apps.test.js new file mode 100644 index 0000000..5524410 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-apps.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getAppsMock from '../../../../../test/mocks/rest/api/v1/apps/get-apps.js'; + +describe('GET /api/v1/apps', () => { + let currentUser, apps, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + apps = await App.findAll(); + }); + + it('should return all apps', async () => { + const response = await request(app) + .get('/api/v1/apps') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(apps); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return all apps filtered by name', async () => { + const appsWithNameGit = apps.filter((app) => app.name.includes('Git')); + + const response = await request(app) + .get('/api/v1/apps?name=Git') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(appsWithNameGit); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return only the apps with triggers', async () => { + const appsWithTriggers = apps.filter((app) => app.triggers?.length > 0); + + const response = await request(app) + .get('/api/v1/apps?onlyWithTriggers=true') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(appsWithTriggers); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return only the apps with actions', async () => { + const appsWithActions = apps.filter((app) => app.actions?.length > 0); + + const response = await request(app) + .get('/api/v1/apps?onlyWithActions=true') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(appsWithActions); + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-auth.js b/packages/backend/src/controllers/api/v1/apps/get-auth.js new file mode 100644 index 0000000..37b3075 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-auth.js @@ -0,0 +1,8 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const auth = await App.findAuthByKey(request.params.appKey); + + renderObject(response, auth, { serializer: 'Auth' }); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-auth.test.js b/packages/backend/src/controllers/api/v1/apps/get-auth.test.js new file mode 100644 index 0000000..6a9a808 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-auth.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getAuthMock from '../../../../../test/mocks/rest/api/v1/apps/get-auth.js'; + +describe('GET /api/v1/apps/:appKey/auth', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the app auth info', async () => { + const exampleApp = await App.findOneByKey('github'); + + const response = await request(app) + .get(`/api/v1/apps/${exampleApp.key}/auth`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAuthMock(exampleApp.auth); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .get('/api/v1/apps/invalid-app-key/auth') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-config.ee.js b/packages/backend/src/controllers/api/v1/apps/get-config.ee.js new file mode 100644 index 0000000..229c20d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-config.ee.js @@ -0,0 +1,15 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import AppConfig from '../../../../models/app-config.js'; + +export default async (request, response) => { + const appConfig = await AppConfig.query() + .withGraphFetched({ + oauthClients: true, + }) + .findOne({ + key: request.params.appKey, + }) + .throwIfNotFound(); + + renderObject(response, appConfig); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js b/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js new file mode 100644 index 0000000..505e492 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js @@ -0,0 +1,43 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getAppConfigMock from '../../../../../test/mocks/rest/api/v1/apps/get-config.js'; +import { createAppConfig } from '../../../../../test/factories/app-config.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/apps/:appKey/config', () => { + let currentUser, appConfig, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + currentUser = await createUser(); + + appConfig = await createAppConfig({ + key: 'deepl', + useOnlyPredefinedAuthClients: false, + disabled: false, + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return specified app config info', async () => { + const response = await request(app) + .get(`/api/v1/apps/${appConfig.key}/config`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppConfigMock(appConfig); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing app key', async () => { + await request(app) + .get('/api/v1/apps/not-existing-app-key/config') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-connections.js b/packages/backend/src/controllers/api/v1/apps/get-connections.js new file mode 100644 index 0000000..0f2fdfc --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-connections.js @@ -0,0 +1,24 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import App from '../../../../models/app.js'; + +export default async (request, response) => { + const app = await App.findOneByKey(request.params.appKey); + + const connections = await request.currentUser.authorizedConnections + .clone() + .select('connections.*') + .withGraphFetched({ + appConfig: true, + oauthClient: true, + }) + .fullOuterJoinRelated('steps') + .where({ + 'connections.key': app.key, + 'connections.draft': false, + }) + .countDistinct('steps.flow_id as flowCount') + .groupBy('connections.id') + .orderBy('created_at', 'desc'); + + renderObject(response, connections); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-connections.test.js b/packages/backend/src/controllers/api/v1/apps/get-connections.test.js new file mode 100644 index 0000000..272f24c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-connections.test.js @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import getConnectionsMock from '../../../../../test/mocks/rest/api/v1/apps/get-connections.js'; + +describe('GET /api/v1/apps/:appKey/connections', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the connections data of specified app for current user', async () => { + const currentUserConnectionOne = await createConnection({ + userId: currentUser.id, + key: 'deepl', + draft: false, + }); + + const currentUserConnectionTwo = await createConnection({ + userId: currentUser.id, + key: 'deepl', + draft: false, + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get('/api/v1/apps/deepl/connections') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getConnectionsMock([ + currentUserConnectionTwo, + currentUserConnectionOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the connections data of specified app for another user', async () => { + const anotherUser = await createUser(); + + const anotherUserConnectionOne = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + draft: false, + }); + + const anotherUserConnectionTwo = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + draft: false, + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get('/api/v1/apps/deepl/connections') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getConnectionsMock([ + anotherUserConnectionTwo, + anotherUserConnectionOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid connection UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .get('/api/v1/apps/invalid-connection-id/connections') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-flows.js b/packages/backend/src/controllers/api/v1/apps/get-flows.js new file mode 100644 index 0000000..3fa79a7 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-flows.js @@ -0,0 +1,24 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import App from '../../../../models/app.js'; +import paginateRest from '../../../../helpers/pagination-rest.js'; + +export default async (request, response) => { + const app = await App.findOneByKey(request.params.appKey); + + const flowsQuery = request.currentUser.authorizedFlows + .clone() + .distinct('flows.*') + .joinRelated({ + steps: true, + }) + .withGraphFetched({ + steps: true, + }) + .where('steps.app_key', app.key) + .orderBy('active', 'desc') + .orderBy('updated_at', 'desc'); + + const flows = await paginateRest(flowsQuery, request.query.page); + + renderObject(response, flows); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-flows.test.js b/packages/backend/src/controllers/api/v1/apps/get-flows.test.js new file mode 100644 index 0000000..7f4ce77 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-flows.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import getFlowsMock from '../../../../../test/mocks/rest/api/v1/flows/get-flows.js'; + +describe('GET /api/v1/apps/:appKey/flows', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the flows data of specified app for current user', async () => { + const currentUserFlowOne = await createFlow({ userId: currentUser.id }); + + const triggerStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'trigger', + appKey: 'webhook', + }); + + const actionStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'action', + }); + + const currentUserFlowTwo = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: currentUserFlowTwo.id, + type: 'trigger', + appKey: 'github', + }); + + await createStep({ + flowId: currentUserFlowTwo.id, + type: 'action', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get('/api/v1/apps/webhook/flows') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [currentUserFlowOne], + [triggerStepFlowOne, actionStepFlowOne] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the flows data of specified app for another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlowOne = await createFlow({ userId: anotherUser.id }); + + const triggerStepFlowOne = await createStep({ + flowId: anotherUserFlowOne.id, + type: 'trigger', + appKey: 'webhook', + }); + + const actionStepFlowOne = await createStep({ + flowId: anotherUserFlowOne.id, + type: 'action', + }); + + const anotherUserFlowTwo = await createFlow({ userId: anotherUser.id }); + + await createStep({ + flowId: anotherUserFlowTwo.id, + type: 'trigger', + appKey: 'github', + }); + + await createStep({ + flowId: anotherUserFlowTwo.id, + type: 'action', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get('/api/v1/apps/webhook/flows') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [anotherUserFlowOne], + [triggerStepFlowOne, actionStepFlowOne] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .get('/api/v1/apps/invalid-app-key/flows') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js new file mode 100644 index 0000000..2577f27 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import OAuthClient from '../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClient = await OAuthClient.query() + .findById(request.params.oauthClientId) + .where({ app_key: request.params.appKey, active: true }) + .throwIfNotFound(); + + renderObject(response, oauthClient); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js new file mode 100644 index 0000000..b39367f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js @@ -0,0 +1,50 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getOAuthClientMock from '../../../../../test/mocks/rest/api/v1/apps/get-oauth-client.js'; +import { createOAuthClient } from '../../../../../test/factories/oauth-client.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/apps/:appKey/oauth-clients/:oauthClientId', () => { + let currentUser, currentOAuthClient, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + currentUser = await createUser(); + currentOAuthClient = await createOAuthClient({ + appKey: 'deepl', + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return specified oauth client', async () => { + const response = await request(app) + .get(`/api/v1/apps/deepl/oauth-clients/${currentOAuthClient.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getOAuthClientMock(currentOAuthClient); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing oauth client ID', async () => { + const notExistingOAuthClientUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/apps/deepl/oauth-clients/${notExistingOAuthClientUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .get('/api/v1/apps/deepl/oauth-clients/invalidOAuthClientUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js new file mode 100644 index 0000000..2a68737 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js @@ -0,0 +1,10 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import OAuthClient from '../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClients = await OAuthClient.query() + .where({ app_key: request.params.appKey, active: true }) + .orderBy('created_at', 'desc'); + + renderObject(response, oauthClients); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js new file mode 100644 index 0000000..4e4b850 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js @@ -0,0 +1,42 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getOAuthClientsMock from '../../../../../test/mocks/rest/api/v1/apps/get-oauth-clients.js'; +import { createOAuthClient } from '../../../../../test/factories/oauth-client.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/apps/:appKey/oauth-clients', () => { + let currentUser, token; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + currentUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return specified oauth client info', async () => { + const oauthClientOne = await createOAuthClient({ + appKey: 'deepl', + }); + + const oauthClientTwo = await createOAuthClient({ + appKey: 'deepl', + }); + + const response = await request(app) + .get('/api/v1/apps/deepl/oauth-clients') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getOAuthClientsMock([ + oauthClientTwo, + oauthClientOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.js b/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.js new file mode 100644 index 0000000..96e542f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.js @@ -0,0 +1,11 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const substeps = await App.findTriggerSubsteps( + request.params.appKey, + request.params.triggerKey + ); + + renderObject(response, substeps); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js b/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js new file mode 100644 index 0000000..e54b6de --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getTriggerSubstepsMock from '../../../../../test/mocks/rest/api/v1/apps/get-trigger-substeps.js'; + +describe('GET /api/v1/apps/:appKey/triggers/:triggerKey/substeps', () => { + let currentUser, exampleApp, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + exampleApp = await App.findOneByKey('github'); + }); + + it('should return the trigger substeps info', async () => { + const triggers = await App.findTriggersByKey('github'); + const exampleTrigger = triggers.find( + (trigger) => trigger.key === 'newIssues' + ); + + const endpointUrl = `/api/v1/apps/${exampleApp.key}/triggers/${exampleTrigger.key}/substeps`; + + const response = await request(app) + .get(endpointUrl) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getTriggerSubstepsMock(exampleTrigger.substeps); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .get('/api/v1/apps/invalid-app-key/triggers/invalid-trigger-key/substeps') + .set('Authorization', token) + .expect(404); + }); + + it('should return empty array for invalid trigger key', async () => { + const endpointUrl = `/api/v1/apps/${exampleApp.key}/triggers/invalid-trigger-key/substeps`; + + const response = await request(app) + .get(endpointUrl) + .set('Authorization', token) + .expect(200); + + expect(response.body.data).toStrictEqual([]); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/apps/get-triggers.js b/packages/backend/src/controllers/api/v1/apps/get-triggers.js new file mode 100644 index 0000000..19cd909 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-triggers.js @@ -0,0 +1,8 @@ +import App from '../../../../models/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const triggers = await App.findTriggersByKey(request.params.appKey); + + renderObject(response, triggers, { serializer: 'Trigger' }); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-triggers.test.js b/packages/backend/src/controllers/api/v1/apps/get-triggers.test.js new file mode 100644 index 0000000..768a4d5 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-triggers.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import App from '../../../../models/app'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import getTriggersMock from '../../../../../test/mocks/rest/api/v1/apps/get-triggers.js'; + +describe('GET /api/v1/apps/:appKey/triggers', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the app triggers', async () => { + const exampleApp = await App.findOneByKey('github'); + + const response = await request(app) + .get(`/api/v1/apps/${exampleApp.key}/triggers`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getTriggersMock(exampleApp.triggers); + expect(expectedPayload).toMatchObject(response.body); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .get('/api/v1/apps/invalid-app-key/triggers') + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/automatisch/config.ee.js b/packages/backend/src/controllers/api/v1/automatisch/config.ee.js new file mode 100644 index 0000000..65f970e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/config.ee.js @@ -0,0 +1,8 @@ +import Config from '../../../../models/config.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const config = await Config.get(); + + renderObject(response, config); +}; diff --git a/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js b/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js new file mode 100644 index 0000000..effb2e9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/config.ee.test.js @@ -0,0 +1,47 @@ +import { vi, expect, describe, it } from 'vitest'; +import request from 'supertest'; +import { updateConfig } from '../../../../../test/factories/config.js'; +import app from '../../../../app.js'; +import configMock from '../../../../../test/mocks/rest/api/v1/automatisch/config.js'; +import * as license from '../../../../helpers/license.ee.js'; +import appConfig from '../../../../config/app.js'; + +describe('GET /api/v1/automatisch/config', () => { + it('should return Automatisch config along with static config', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + vi.spyOn(appConfig, 'disableNotificationsPage', 'get').mockReturnValue( + true + ); + vi.spyOn(appConfig, 'disableFavicon', 'get').mockReturnValue(true); + vi.spyOn(appConfig, 'additionalDrawerLink', 'get').mockReturnValue('link'); + vi.spyOn(appConfig, 'additionalDrawerLinkIcon', 'get').mockReturnValue( + 'icon' + ); + vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue( + 'text' + ); + + const config = await updateConfig({ + logoSvgData: 'Sample', + palettePrimaryDark: '#001f52', + palettePrimaryLight: '#4286FF', + palettePrimaryMain: '#0059F7', + title: 'Sample Title', + }); + + const response = await request(app) + .get('/api/v1/automatisch/config') + .expect(200); + + const expectedPayload = configMock({ + ...config, + disableNotificationsPage: true, + disableFavicon: true, + additionalDrawerLink: 'link', + additionalDrawerLinkIcon: 'icon', + additionalDrawerLinkText: 'text', + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/automatisch/info.js b/packages/backend/src/controllers/api/v1/automatisch/info.js new file mode 100644 index 0000000..07bbd6b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/info.js @@ -0,0 +1,18 @@ +import appConfig from '../../../../config/app.js'; +import { hasValidLicense } from '../../../../helpers/license.ee.js'; +import { renderObject } from '../../../../helpers/renderer.js'; +import Config from '../../../../models/config.js'; + +export default async (request, response) => { + const installationCompleted = await Config.isInstallationCompleted(); + + const info = { + docsUrl: appConfig.docsUrl, + installationCompleted, + isCloud: appConfig.isCloud, + isEnterprise: await hasValidLicense(), + isMation: appConfig.isMation, + }; + + renderObject(response, info); +}; diff --git a/packages/backend/src/controllers/api/v1/automatisch/info.test.js b/packages/backend/src/controllers/api/v1/automatisch/info.test.js new file mode 100644 index 0000000..431a3d9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/info.test.js @@ -0,0 +1,25 @@ +import { vi, expect, describe, it } from 'vitest'; +import request from 'supertest'; +import appConfig from '../../../../config/app.js'; +import Config from '../../../../models/config.js'; +import app from '../../../../app.js'; +import infoMock from '../../../../../test/mocks/rest/api/v1/automatisch/info.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/automatisch/info', () => { + it('should return Automatisch info', async () => { + vi.spyOn(Config, 'isInstallationCompleted').mockResolvedValue(true); + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false); + vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(false); + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + vi.spyOn(appConfig, 'docsUrl', 'get').mockReturnValue('https://automatisch.io/docs'); + + const response = await request(app) + .get('/api/v1/automatisch/info') + .expect(200); + + const expectedPayload = infoMock(); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/automatisch/license.js b/packages/backend/src/controllers/api/v1/automatisch/license.js new file mode 100644 index 0000000..65f6f4b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/license.js @@ -0,0 +1,15 @@ +import { getLicense } from '../../../../helpers/license.ee.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const license = await getLicense(); + + const computedLicense = { + id: license ? license.id : null, + name: license ? license.name : null, + expireAt: license ? license.expireAt : null, + verified: license ? true : false, + }; + + renderObject(response, computedLicense); +}; diff --git a/packages/backend/src/controllers/api/v1/automatisch/license.test.js b/packages/backend/src/controllers/api/v1/automatisch/license.test.js new file mode 100644 index 0000000..a35567c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/license.test.js @@ -0,0 +1,23 @@ +import { vi, expect, describe, it } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import licenseMock from '../../../../../test/mocks/rest/api/v1/automatisch/license.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/automatisch/license', () => { + it('should return Automatisch license info', async () => { + vi.spyOn(license, 'getLicense').mockResolvedValue({ + id: '123', + name: 'license-name', + expireAt: '2025-12-31T23:59:59Z', + }); + + const response = await request(app) + .get('/api/v1/automatisch/license') + .expect(200); + + const expectedPayload = licenseMock(); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/automatisch/notifications.js b/packages/backend/src/controllers/api/v1/automatisch/notifications.js new file mode 100644 index 0000000..e42d65f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/notifications.js @@ -0,0 +1,19 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import axios from '../../../../helpers/axios-with-proxy.js'; +import logger from '../../../../helpers/logger.js'; + +const NOTIFICATIONS_URL = + 'https://notifications.automatisch.io/notifications.json'; + +export default async (request, response) => { + let notifications = []; + + try { + const response = await axios.get(NOTIFICATIONS_URL); + notifications = response.data; + } catch (error) { + logger.error('Error fetching notifications API endpoint!', error); + } + + renderObject(response, notifications); +}; diff --git a/packages/backend/src/controllers/api/v1/automatisch/notifications.test.js b/packages/backend/src/controllers/api/v1/automatisch/notifications.test.js new file mode 100644 index 0000000..ddfdd90 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/notifications.test.js @@ -0,0 +1,9 @@ +import { describe, it } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; + +describe('GET /api/v1/automatisch/notifications', () => { + it('should return Automatisch notifications', async () => { + await request(app).get('/api/v1/automatisch/notifications').expect(200); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/automatisch/version.js b/packages/backend/src/controllers/api/v1/automatisch/version.js new file mode 100644 index 0000000..1f8915f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/version.js @@ -0,0 +1,6 @@ +import appConfig from '../../../../config/app.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + renderObject(response, { version: appConfig.version }); +}; diff --git a/packages/backend/src/controllers/api/v1/automatisch/version.test.js b/packages/backend/src/controllers/api/v1/automatisch/version.test.js new file mode 100644 index 0000000..244c795 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/automatisch/version.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; + +describe('GET /api/v1/automatisch/version', () => { + it('should return Automatisch version', async () => { + const response = await request(app) + .get('/api/v1/automatisch/version') + .expect(200); + + const expectedPayload = { + data: { + version: '0.14.0', + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/delete-connection.js b/packages/backend/src/controllers/api/v1/connections/delete-connection.js new file mode 100644 index 0000000..e63e5a7 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/delete-connection.js @@ -0,0 +1,11 @@ +export default async (request, response) => { + await request.currentUser + .$relatedQuery('connections') + .delete() + .findOne({ + id: request.params.connectionId, + }) + .throwIfNotFound(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/connections/delete-connection.test.js b/packages/backend/src/controllers/api/v1/connections/delete-connection.test.js new file mode 100644 index 0000000..bbac540 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/delete-connection.test.js @@ -0,0 +1,77 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('DELETE /api/v1/connections/:connectionId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + await createPermission({ + action: 'delete', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should delete the connection for current user', async () => { + const currentUserConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + verified: true, + }); + + await request(app) + .delete(`/api/v1/connections/${currentUserConnection.id}`) + .set('Authorization', token) + .expect(204); + }); + + it(`should return not found for other users' connections`, async () => { + const anotherUser = await createUser(); + + const anotherUserConnection = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + verified: true, + }); + + await request(app) + .post(`/api/v1/connections/${anotherUserConnection.id}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for not existing connection UUID', async () => { + const notExistingConnectionUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/connections/${notExistingConnectionUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .delete('/api/v1/connections/invalidConnectionUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/generate-auth-url.js b/packages/backend/src/controllers/api/v1/connections/generate-auth-url.js new file mode 100644 index 0000000..167190c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/generate-auth-url.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let connection = await request.currentUser + .$relatedQuery('connections') + .findOne({ + id: request.params.connectionId, + }) + .throwIfNotFound(); + + connection = await connection.generateAuthUrl(); + + renderObject(response, connection); +}; diff --git a/packages/backend/src/controllers/api/v1/connections/generate-auth-url.test.js b/packages/backend/src/controllers/api/v1/connections/generate-auth-url.test.js new file mode 100644 index 0000000..8dee64e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/generate-auth-url.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/connections/:connectionId/auth-url', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should generate auth url for the connection', async () => { + const connection = await createConnection({ + userId: currentUser.id, + key: 'gitlab', + formattedData: { + clientId: 'CLIENT_ID', + oAuthRedirectUrl: 'http://localhost:3001/app/gitlab/connections/add', + }, + verified: false, + }); + + const response = await request(app) + .post(`/api/v1/connections/${connection.id}/auth-url`) + .set('Authorization', token) + .expect(200); + + expect(response.body.data).toStrictEqual({ + url: expect.stringContaining('https://gitlab.com/oauth/authorize?'), + }); + + expect(response.body.data).toStrictEqual({ + url: expect.stringContaining('client_id=CLIENT_ID'), + }); + + expect(response.body.data).toStrictEqual({ + url: expect.stringContaining( + `redirect_uri=${encodeURIComponent( + 'http://localhost:3001/app/gitlab/connections/add' + )}` + ), + }); + }); + + it(`should return internal server error response for invalid connection data`, async () => { + const connection = await createConnection({ + userId: currentUser.id, + key: 'gitlab', + formattedData: { + instanceUrl: 123, + }, + verified: false, + }); + + await request(app) + .post(`/api/v1/connections/${connection.id}/auth-url`) + .set('Authorization', token) + .expect(500); + }); + + it('should return not found response for not existing connection UUID', async () => { + const notExistingConnectionUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/connections/${notExistingConnectionUUID}/auth-url`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await request(app) + .post('/api/v1/connections/invalidConnectionUUID/auth-url') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/get-flows.js b/packages/backend/src/controllers/api/v1/connections/get-flows.js new file mode 100644 index 0000000..b48a80a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/get-flows.js @@ -0,0 +1,21 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import paginateRest from '../../../../helpers/pagination-rest.js'; + +export default async (request, response) => { + const flowsQuery = request.currentUser.authorizedFlows + .clone() + .distinct('flows.*') + .joinRelated({ + steps: true, + }) + .withGraphFetched({ + steps: true, + }) + .where('steps.connection_id', request.params.connectionId) + .orderBy('active', 'desc') + .orderBy('updated_at', 'desc'); + + const flows = await paginateRest(flowsQuery, request.query.page); + + renderObject(response, flows); +}; diff --git a/packages/backend/src/controllers/api/v1/connections/get-flows.test.js b/packages/backend/src/controllers/api/v1/connections/get-flows.test.js new file mode 100644 index 0000000..1f59672 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/get-flows.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import getFlowsMock from '../../../../../test/mocks/rest/api/v1/flows/get-flows.js'; + +describe('GET /api/v1/connections/:connectionId/flows', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the flows data of specified connection for current user', async () => { + const currentUserFlowOne = await createFlow({ userId: currentUser.id }); + + const currentUserConnection = await createConnection({ + userId: currentUser.id, + key: 'webhook', + }); + + const triggerStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'trigger', + appKey: 'webhook', + connectionId: currentUserConnection.id, + }); + + const actionStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'action', + }); + + const currentUserFlowTwo = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: currentUserFlowTwo.id, + type: 'trigger', + appKey: 'github', + }); + + await createStep({ + flowId: currentUserFlowTwo.id, + type: 'action', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/connections/${currentUserConnection.id}/flows`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [currentUserFlowOne], + [triggerStepFlowOne, actionStepFlowOne] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the flows data of specified connection for another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlowOne = await createFlow({ userId: anotherUser.id }); + + const anotherUserConnection = await createConnection({ + userId: anotherUser.id, + key: 'webhook', + }); + + const triggerStepFlowOne = await createStep({ + flowId: anotherUserFlowOne.id, + type: 'trigger', + appKey: 'webhook', + connectionId: anotherUserConnection.id, + }); + + const actionStepFlowOne = await createStep({ + flowId: anotherUserFlowOne.id, + type: 'action', + }); + + const anotherUserFlowTwo = await createFlow({ userId: anotherUser.id }); + + await createStep({ + flowId: anotherUserFlowTwo.id, + type: 'trigger', + appKey: 'github', + }); + + await createStep({ + flowId: anotherUserFlowTwo.id, + type: 'action', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/connections/${anotherUserConnection.id}/flows`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [anotherUserFlowOne], + [triggerStepFlowOne, actionStepFlowOne] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/reset-connection.js b/packages/backend/src/controllers/api/v1/connections/reset-connection.js new file mode 100644 index 0000000..2d7fa6a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/reset-connection.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let connection = await request.currentUser + .$relatedQuery('connections') + .findOne({ + id: request.params.connectionId, + }) + .throwIfNotFound(); + + connection = await connection.reset(); + + renderObject(response, connection); +}; diff --git a/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js b/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js new file mode 100644 index 0000000..2e94c5d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import resetConnectionMock from '../../../../../test/mocks/rest/api/v1/connections/reset-connection.js'; + +describe('POST /api/v1/connections/:connectionId/reset', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it(`should reset the connection's formatted data`, async () => { + const currentUserConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + verified: true, + formattedData: { + screenName: 'Connection name', + clientSecret: 'secret', + clientId: 'id', + token: 'token', + }, + }); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/connections/${currentUserConnection.id}/reset`) + .set('Authorization', token) + .expect(200); + + const refetchedCurrentUserConnection = await currentUserConnection.$query(); + + const expectedPayload = resetConnectionMock({ + ...refetchedCurrentUserConnection, + formattedData: { + screenName: 'Connection name', + }, + }); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedCurrentUserConnection.formattedData).toStrictEqual( + expectedPayload.data.formattedData + ); + }); + + it('should return not found response for another user', async () => { + const anotherUser = await createUser(); + + const anotherUserConnection = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + verified: true, + }); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .post(`/api/v1/connections/${anotherUserConnection.id}/reset`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for not existing connection UUID', async () => { + const notExistingConnectionUUID = Crypto.randomUUID(); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/connections/${notExistingConnectionUUID}/reset`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/connections/invalidConnectionUUID/reset') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/test-connection.js b/packages/backend/src/controllers/api/v1/connections/test-connection.js new file mode 100644 index 0000000..3213258 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/test-connection.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let connection = await request.currentUser.authorizedConnections + .clone() + .findOne({ + id: request.params.connectionId, + }) + .throwIfNotFound(); + + connection = await connection.testAndUpdateConnection(); + + renderObject(response, connection); +}; diff --git a/packages/backend/src/controllers/api/v1/connections/test-connection.test.js b/packages/backend/src/controllers/api/v1/connections/test-connection.test.js new file mode 100644 index 0000000..8d11f90 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/test-connection.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/connections/:connectionId/test', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should update the connection as not verified for current user', async () => { + const currentUserConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + verified: true, + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/connections/${currentUserConnection.id}/test`) + .set('Authorization', token) + .expect(200); + + expect(response.body.data.verified).toStrictEqual(false); + }); + + it('should update the connection as not verified for another user', async () => { + const anotherUser = await createUser(); + + const anotherUserConnection = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + verified: true, + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/connections/${anotherUserConnection.id}/test`) + .set('Authorization', token) + .expect(200); + + expect(response.body.data.verified).toStrictEqual(false); + }); + + it('should return not found response for not existing connection UUID', async () => { + const notExistingConnectionUUID = Crypto.randomUUID(); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/connections/${notExistingConnectionUUID}/test`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/connections/invalidConnectionUUID/test') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/update-connection.js b/packages/backend/src/controllers/api/v1/connections/update-connection.js new file mode 100644 index 0000000..979aa73 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/update-connection.js @@ -0,0 +1,19 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let connection = await request.currentUser + .$relatedQuery('connections') + .findOne({ + id: request.params.connectionId, + }) + .throwIfNotFound(); + + connection = await connection.updateFormattedData(connectionParams(request)); + + renderObject(response, connection); +}; + +const connectionParams = (request) => { + const { formattedData, oauthClientId } = request.body; + return { formattedData, oauthClientId }; +}; diff --git a/packages/backend/src/controllers/api/v1/connections/update-connection.test.js b/packages/backend/src/controllers/api/v1/connections/update-connection.test.js new file mode 100644 index 0000000..5902e36 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/update-connection.test.js @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateConnectionMock from '../../../../../test/mocks/rest/api/v1/connections/update-connection.js'; + +describe('PATCH /api/v1/connections/:connectionId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should update the connection with valid data for current user', async () => { + const connectionData = { + userId: currentUser.id, + key: 'deepl', + verified: true, + formattedData: { + screenName: 'Connection name', + clientSecret: 'secret', + clientId: 'id', + token: 'token', + }, + }; + + const currentUserConnection = await createConnection(connectionData); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/connections/${currentUserConnection.id}`) + .set('Authorization', token) + .send({ + formattedData: { + screenName: 'New connection name', + clientSecret: 'new secret', + clientId: 'new id', + token: 'new token', + }, + }) + .expect(200); + + const refetchedCurrentUserConnection = await currentUserConnection.$query(); + + const expectedPayload = updateConnectionMock( + refetchedCurrentUserConnection + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for another user', async () => { + const anotherUser = await createUser(); + + const anotherUserConnection = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + verified: true, + }); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .patch(`/api/v1/connections/${anotherUserConnection.id}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for not existing connection UUID', async () => { + const notExistingConnectionUUID = Crypto.randomUUID(); + + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch(`/api/v1/connections/${notExistingConnectionUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch('/api/v1/connections/invalidConnectionUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/connections/verify-connection.js b/packages/backend/src/controllers/api/v1/connections/verify-connection.js new file mode 100644 index 0000000..2004f19 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/verify-connection.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let connection = await request.currentUser + .$relatedQuery('connections') + .findOne({ + id: request.params.connectionId, + }) + .throwIfNotFound(); + + connection = await connection.verifyAndUpdateConnection(); + + renderObject(response, connection); +}; diff --git a/packages/backend/src/controllers/api/v1/connections/verify-connection.test.js b/packages/backend/src/controllers/api/v1/connections/verify-connection.test.js new file mode 100644 index 0000000..4fd6f97 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/connections/verify-connection.test.js @@ -0,0 +1,82 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import App from '../../../../models/app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/connections/:connectionId/verify', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should update the connection as verified for current user', async () => { + const currentUserConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + verified: true, + }); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + vi.spyOn(App, 'findOneByKey').mockImplementation((key) => { + if (key !== currentUserConnection.key) return; + + return { + auth: { + verifyCredentials: vi.fn().mockResolvedValue(), + }, + }; + }); + + const response = await request(app) + .post(`/api/v1/connections/${currentUserConnection.id}/verify`) + .set('Authorization', token) + .expect(200); + + expect(response.body.data.verified).toStrictEqual(true); + }); + + it('should return not found response for not existing connection UUID', async () => { + const notExistingConnectionUUID = Crypto.randomUUID(); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/connections/${notExistingConnectionUUID}/verify`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/connections/invalidConnectionUUID/verify') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/executions/get-execution-steps.js b/packages/backend/src/controllers/api/v1/executions/get-execution-steps.js new file mode 100644 index 0000000..f90d243 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-execution-steps.js @@ -0,0 +1,23 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import paginateRest from '../../../../helpers/pagination-rest.js'; + +export default async (request, response) => { + const execution = await request.currentUser.authorizedExecutions + .clone() + .withSoftDeleted() + .findById(request.params.executionId) + .throwIfNotFound(); + + const executionStepsQuery = execution + .$relatedQuery('executionSteps') + .withSoftDeleted() + .withGraphFetched('step') + .orderBy('created_at', 'asc'); + + const executionSteps = await paginateRest( + executionStepsQuery, + request.query.page + ); + + renderObject(response, executionSteps); +}; diff --git a/packages/backend/src/controllers/api/v1/executions/get-execution-steps.test.js b/packages/backend/src/controllers/api/v1/executions/get-execution-steps.test.js new file mode 100644 index 0000000..58a326a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-execution-steps.test.js @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createExecution } from '../../../../../test/factories/execution.js'; +import { createExecutionStep } from '../../../../../test/factories/execution-step.js'; +import { createPermission } from '../../../../../test/factories/permission'; +import getExecutionStepsMock from '../../../../../test/mocks/rest/api/v1/executions/get-execution-steps'; + +describe('GET /api/v1/executions/:executionId/execution-steps', () => { + let currentUser, currentUserRole, anotherUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + anotherUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the execution steps of current user execution', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + }); + + const stepOne = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + }); + + const stepTwo = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + }); + + const currentUserExecution = await createExecution({ + flowId: currentUserFlow.id, + }); + + const currentUserExecutionStepOne = await createExecutionStep({ + executionId: currentUserExecution.id, + stepId: stepOne.id, + }); + + const currentUserExecutionStepTwo = await createExecutionStep({ + executionId: currentUserExecution.id, + stepId: stepTwo.id, + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/executions/${currentUserExecution.id}/execution-steps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionStepsMock( + [currentUserExecutionStepOne, currentUserExecutionStepTwo], + [stepOne, stepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the execution steps of another user execution', async () => { + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + }); + + const stepOne = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + }); + + const stepTwo = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + }); + + const anotherUserExecution = await createExecution({ + flowId: anotherUserFlow.id, + }); + + const anotherUserExecutionStepOne = await createExecutionStep({ + executionId: anotherUserExecution.id, + stepId: stepOne.id, + }); + + const anotherUserExecutionStepTwo = await createExecutionStep({ + executionId: anotherUserExecution.id, + stepId: stepTwo.id, + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/executions/${anotherUserExecution.id}/execution-steps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionStepsMock( + [anotherUserExecutionStepOne, anotherUserExecutionStepTwo], + [stepOne, stepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing execution step UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingExcecutionUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/executions/${notExistingExcecutionUUID}/execution-steps`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get('/api/v1/executions/invalidExecutionUUID/execution-steps') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/executions/get-execution.js b/packages/backend/src/controllers/api/v1/executions/get-execution.js new file mode 100644 index 0000000..fe6d599 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-execution.js @@ -0,0 +1,16 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const execution = await request.currentUser.authorizedExecutions + .clone() + .withGraphFetched({ + flow: { + steps: true, + }, + }) + .withSoftDeleted() + .findById(request.params.executionId) + .throwIfNotFound(); + + renderObject(response, execution); +}; diff --git a/packages/backend/src/controllers/api/v1/executions/get-execution.test.js b/packages/backend/src/controllers/api/v1/executions/get-execution.test.js new file mode 100644 index 0000000..b447882 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-execution.test.js @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createExecution } from '../../../../../test/factories/execution.js'; +import { createPermission } from '../../../../../test/factories/permission'; +import getExecutionMock from '../../../../../test/mocks/rest/api/v1/executions/get-execution'; + +describe('GET /api/v1/executions/:executionId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the execution data of current user', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + }); + + const stepOne = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + }); + + const stepTwo = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + }); + + const currentUserExecution = await createExecution({ + flowId: currentUserFlow.id, + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/executions/${currentUserExecution.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionMock( + currentUserExecution, + currentUserFlow, + [stepOne, stepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the execution data of another user', async () => { + const anotherUser = await createUser(); + + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + }); + + const stepOne = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + }); + + const stepTwo = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + }); + + const anotherUserExecution = await createExecution({ + flowId: anotherUserFlow.id, + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/executions/${anotherUserExecution.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionMock( + anotherUserExecution, + anotherUserFlow, + [stepOne, stepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing execution UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingExcecutionUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/executions/${notExistingExcecutionUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get('/api/v1/executions/invalidExecutionUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/executions/get-executions.js b/packages/backend/src/controllers/api/v1/executions/get-executions.js new file mode 100644 index 0000000..bb4ca70 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-executions.js @@ -0,0 +1,27 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import paginateRest from '../../../../helpers/pagination-rest.js'; + +export default async (request, response) => { + const executionsQuery = request.currentUser.authorizedExecutions + .clone() + .withSoftDeleted() + .orderBy('created_at', 'desc') + .withGraphFetched({ + flow: { + steps: true, + }, + }); + + const executions = await paginateRest(executionsQuery, request.query.page); + + for (const execution of executions.records) { + const executionSteps = await execution.$relatedQuery('executionSteps'); + const status = executionSteps.some((step) => step.status === 'failure') + ? 'failure' + : 'success'; + + execution.status = status; + } + + renderObject(response, executions); +}; diff --git a/packages/backend/src/controllers/api/v1/executions/get-executions.test.js b/packages/backend/src/controllers/api/v1/executions/get-executions.test.js new file mode 100644 index 0000000..ebf9b0c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-executions.test.js @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createExecution } from '../../../../../test/factories/execution.js'; +import { createPermission } from '../../../../../test/factories/permission'; +import getExecutionsMock from '../../../../../test/mocks/rest/api/v1/executions/get-executions'; + +describe('GET /api/v1/executions', () => { + let currentUser, currentUserRole, anotherUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + anotherUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the executions of current user', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + }); + + const stepOne = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + }); + + const stepTwo = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + }); + + const currentUserExecutionOne = await createExecution({ + flowId: currentUserFlow.id, + }); + + const currentUserExecutionTwo = await createExecution({ + flowId: currentUserFlow.id, + }); + + await currentUserExecutionTwo + .$query() + .patchAndFetch({ deletedAt: new Date().toISOString() }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get('/api/v1/executions') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionsMock( + [currentUserExecutionTwo, currentUserExecutionOne], + currentUserFlow, + [stepOne, stepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the executions of another user', async () => { + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + }); + + const stepOne = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + }); + + const stepTwo = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + }); + + const anotherUserExecutionOne = await createExecution({ + flowId: anotherUserFlow.id, + }); + + const anotherUserExecutionTwo = await createExecution({ + flowId: anotherUserFlow.id, + }); + + await anotherUserExecutionTwo + .$query() + .patchAndFetch({ deletedAt: new Date().toISOString() }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get('/api/v1/executions') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionsMock( + [anotherUserExecutionTwo, anotherUserExecutionOne], + anotherUserFlow, + [stepOne, stepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/create-flow.js b/packages/backend/src/controllers/api/v1/flows/create-flow.js new file mode 100644 index 0000000..39d12f3 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/create-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.$relatedQuery('flows').insertAndFetch({ + name: 'Name your flow', + }); + + await flow.createInitialSteps(); + + renderObject(response, flow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/create-flow.test.js b/packages/backend/src/controllers/api/v1/flows/create-flow.test.js new file mode 100644 index 0000000..fb8f563 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/create-flow.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import createFlowMock from '../../../../../test/mocks/rest/api/v1/flows/create-flow.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/flows', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created flow', async () => { + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post('/api/v1/flows') + .set('Authorization', token) + .expect(201); + + const refetchedFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const expectedPayload = await createFlowMock(refetchedFlow); + + expect(response.body).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/create-step.js b/packages/backend/src/controllers/api/v1/flows/create-step.js new file mode 100644 index 0000000..b1c49e3 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/create-step.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .clone() + .findById(request.params.flowId) + .throwIfNotFound(); + + const createdActionStep = await flow.createStepAfter( + request.body.previousStepId + ); + + renderObject(response, createdActionStep, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/create-step.test.js b/packages/backend/src/controllers/api/v1/flows/create-step.test.js new file mode 100644 index 0000000..efc599b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/create-step.test.js @@ -0,0 +1,176 @@ +import Crypto from 'node:crypto'; +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; + +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import createStepMock from '../../../../../test/mocks/rest/api/v1/flows/create-step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/flows/:flowId/steps', () => { + let currentUser, flow, triggerStep, token; + + beforeEach(async () => { + currentUser = await createUser(); + + flow = await createFlow({ userId: currentUser.id }); + + triggerStep = await createStep({ flowId: flow.id, type: 'trigger' }); + + await createStep({ flowId: flow.id, type: 'action' }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created step for current user', async () => { + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'update', + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${flow.id}/steps`) + .set('Authorization', token) + .send({ + previousStepId: triggerStep.id, + }) + .expect(201); + + const expectedPayload = await createStepMock({ + id: response.body.data.id, + position: 2, + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return created step for another user', async () => { + const anotherUser = await createUser(); + + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const anotherUserFlowTriggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + }); + + await createStep({ flowId: anotherUserFlow.id, type: 'action' }); + + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'update', + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/steps`) + .set('Authorization', token) + .send({ + previousStepId: anotherUserFlowTriggerStep.id, + }) + .expect(201); + + const expectedPayload = await createStepMock({ + id: response.body.data.id, + position: 2, + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return bad request response for invalid flow UUID', async () => { + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'update', + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/steps') + .set('Authorization', token) + .send({ + previousStepId: triggerStep.id, + }) + .expect(400); + }); + + it('should return not found response for invalid flow UUID', async () => { + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'update', + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/steps`) + .set('Authorization', token) + .send({ + previousStepId: triggerStep.id, + }) + .expect(404); + }); + + it('should return not found response for invalid flow UUID', async () => { + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + await createPermission({ + roleId: currentUser.roleId, + subject: 'Flow', + action: 'update', + conditions: ['isCreator'], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${flow.id}/steps`) + .set('Authorization', token) + .send({ + previousStepId: notExistingStepUUID, + }) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/delete-flow.js b/packages/backend/src/controllers/api/v1/flows/delete-flow.js new file mode 100644 index 0000000..f4be3da --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/delete-flow.js @@ -0,0 +1,10 @@ +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .clone() + .findById(request.params.flowId) + .throwIfNotFound(); + + await flow.delete(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/delete-flow.test.js b/packages/backend/src/controllers/api/v1/flows/delete-flow.test.js new file mode 100644 index 0000000..8410312 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/delete-flow.test.js @@ -0,0 +1,110 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('DELETE /api/v1/flows/:flowId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should remove the current user flow and return no content', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .delete(`/api/v1/flows/${currentUserFlow.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should remove another user flow and return no content', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .delete(`/api/v1/flows/${anotherUserFlow.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/flows/${notExistingFlowUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .delete('/api/v1/flows/invalidFlowUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/duplicate-flow.js b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.js new file mode 100644 index 0000000..a4c7d58 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const duplicatedFlow = await flow.duplicateFor(request.currentUser); + + renderObject(response, duplicatedFlow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js new file mode 100644 index 0000000..924b4f1 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import duplicateFlowMock from '../../../../../test/mocks/rest/api/v1/flows/duplicate-flow.js'; + +describe('POST /api/v1/flows/:flowId/duplicate', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return duplicated flow data of current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/duplicate`) + .set('Authorization', token) + .expect(201); + + const refetchedDuplicateFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedDuplicateFlowSteps = await refetchedDuplicateFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await duplicateFlowMock( + refetchedDuplicateFlow, + refetchedDuplicateFlowSteps + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedDuplicateFlow.userId).toStrictEqual(currentUser.id); + }); + + it('should return duplicated flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/duplicate`) + .set('Authorization', token) + .expect(201); + + const refetchedDuplicateFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedDuplicateFlowSteps = await refetchedDuplicateFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await duplicateFlowMock( + refetchedDuplicateFlow, + refetchedDuplicateFlowSteps + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedDuplicateFlow.userId).toStrictEqual(currentUser.id); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/duplicate`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/duplicate`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/duplicate') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js new file mode 100644 index 0000000..5a1faac --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const exportedFlow = await flow.export(); + + return renderObject(response, exportedFlow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js new file mode 100644 index 0000000..add5ae1 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import exportFlowMock from '../../../../../test/mocks/rest/api/v1/flows/export-flow.js'; + +describe('POST /api/v1/flows/:flowId/export', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should export the flow data of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should export the flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${anotherUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/export') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/get-flow.js b/packages/backend/src/controllers/api/v1/flows/get-flow.js new file mode 100644 index 0000000..0047469 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/get-flow.js @@ -0,0 +1,12 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .clone() + .withGraphJoined({ steps: true }) + .orderBy('steps.position', 'asc') + .findOne({ 'flows.id': request.params.flowId }) + .throwIfNotFound(); + + renderObject(response, flow); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/get-flow.test.js b/packages/backend/src/controllers/api/v1/flows/get-flow.test.js new file mode 100644 index 0000000..19357ce --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/get-flow.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; +import getFlowMock from '../../../../../test/mocks/rest/api/v1/flows/get-flow'; + +describe('GET /api/v1/flows/:flowId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the flow data of current user', async () => { + const currentUserflow = await createFlow({ userId: currentUser.id }); + const triggerStep = await createStep({ flowId: currentUserflow.id }); + const actionStep = await createStep({ flowId: currentUserflow.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/flows/${currentUserflow.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowMock(currentUserflow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const triggerStep = await createStep({ flowId: anotherUserFlow.id }); + const actionStep = await createStep({ flowId: anotherUserFlow.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/flows/${anotherUserFlow.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/flows/${notExistingFlowUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get('/api/v1/flows/invalidFlowUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/get-flows.js b/packages/backend/src/controllers/api/v1/flows/get-flows.js new file mode 100644 index 0000000..92e79fb --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/get-flows.js @@ -0,0 +1,21 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import paginateRest from '../../../../helpers/pagination-rest.js'; + +export default async (request, response) => { + const flowsQuery = request.currentUser.authorizedFlows + .clone() + .withGraphFetched({ + steps: true, + }) + .where((builder) => { + if (request.query.name) { + builder.where('flows.name', 'ilike', `%${request.query.name}%`); + } + }) + .orderBy('active', 'desc') + .orderBy('updated_at', 'desc'); + + const flows = await paginateRest(flowsQuery, request.query.page); + + renderObject(response, flows); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/get-flows.test.js b/packages/backend/src/controllers/api/v1/flows/get-flows.test.js new file mode 100644 index 0000000..3af8537 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/get-flows.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; +import getFlowsMock from '../../../../../test/mocks/rest/api/v1/flows/get-flows.js'; + +describe('GET /api/v1/flows', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the flows data of current user', async () => { + const currentUserFlowOne = await createFlow({ userId: currentUser.id }); + + const triggerStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'trigger', + }); + const actionStepFlowOne = await createStep({ + flowId: currentUserFlowOne.id, + type: 'action', + }); + + const currentUserFlowTwo = await createFlow({ userId: currentUser.id }); + + const triggerStepFlowTwo = await createStep({ + flowId: currentUserFlowTwo.id, + type: 'trigger', + }); + const actionStepFlowTwo = await createStep({ + flowId: currentUserFlowTwo.id, + type: 'action', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get('/api/v1/flows') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [currentUserFlowTwo, currentUserFlowOne], + [ + triggerStepFlowOne, + actionStepFlowOne, + triggerStepFlowTwo, + actionStepFlowTwo, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the flows data of another user', async () => { + const anotherUser = await createUser(); + + const anotherUserFlowOne = await createFlow({ userId: anotherUser.id }); + + const triggerStepFlowOne = await createStep({ + flowId: anotherUserFlowOne.id, + type: 'trigger', + }); + const actionStepFlowOne = await createStep({ + flowId: anotherUserFlowOne.id, + type: 'action', + }); + + const anotherUserFlowTwo = await createFlow({ userId: anotherUser.id }); + + const triggerStepFlowTwo = await createStep({ + flowId: anotherUserFlowTwo.id, + type: 'trigger', + }); + const actionStepFlowTwo = await createStep({ + flowId: anotherUserFlowTwo.id, + type: 'action', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get('/api/v1/flows') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFlowsMock( + [anotherUserFlowTwo, anotherUserFlowOne], + [ + triggerStepFlowOne, + actionStepFlowOne, + triggerStepFlowTwo, + actionStepFlowTwo, + ] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/import-flow.js b/packages/backend/src/controllers/api/v1/flows/import-flow.js new file mode 100644 index 0000000..c64d0f9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/import-flow.js @@ -0,0 +1,29 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import importFlow from '../../../../helpers/import-flow.js'; + +export default async function importFlowController(request, response) { + const flow = await importFlow( + request.currentUser, + flowParams(request), + response + ); + + return renderObject(response, flow, { status: 201 }); +} + +const flowParams = (request) => { + return { + id: request.body.id, + name: request.body.name, + steps: request.body.steps.map((step) => ({ + id: step.id, + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: step.parameters, + position: step.position, + webhookPath: step.webhookPath, + })), + }; +}; diff --git a/packages/backend/src/controllers/api/v1/flows/import-flow.test.js b/packages/backend/src/controllers/api/v1/flows/import-flow.test.js new file mode 100644 index 0000000..2915c48 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/import-flow.test.js @@ -0,0 +1,355 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import importFlowMock from '../../../../../test/mocks/rest/api/v1/flows/import-flow.js'; + +describe('POST /api/v1/flows/import', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should import the flow data', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const expectedPayload = await importFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should have correct parameters of the steps', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newTriggerParameters = response.body.data.steps[0].parameters; + const newActionParameters = response.body.data.steps[1].parameters; + const newTriggerStepId = response.body.data.steps[0].id; + + expect(newTriggerParameters).toMatchObject({ + workSynchronously: true, + }); + + expect(newActionParameters).toMatchObject({ + input: `hello {{step.${newTriggerStepId}.query.sample}} world`, + transform: 'capitalize', + }); + }); + + it('should have the new flow id in the new webhook url', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newWebhookUrl = response.body.data.steps[0].webhookUrl; + + expect(newWebhookUrl).toContain(`/webhooks/flows/${response.body.data.id}`); + }); + + it('should have the first step id in the input parameter of the second step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newTriggerStepId = response.body.data.steps[0].id; + const newActionStepInputParameter = + response.body.data.steps[1].parameters.input; + + expect(newActionStepInputParameter).toContain( + `{{step.${newTriggerStepId}.query.sample}}` + ); + }); + + it('should throw an error in case there is no trigger step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(422); + + expect(response.body.errors.steps).toStrictEqual([ + 'The first step must be a trigger!', + ]); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-folder.js b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.js new file mode 100644 index 0000000..2decc11 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let flow = await request.currentUser + .$relatedQuery('flows') + .findOne({ + id: request.params.flowId, + }) + .throwIfNotFound(); + + flow = await flow.updateFolder(request.body.folderId); + + renderObject(response, flow); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js new file mode 100644 index 0000000..0abdef5 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createFolder } from '../../../../../test/factories/folder.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateFlowFolderMock from '../../../../../test/mocks/rest/api/v1/flows/update-flow-folder.js'; + +describe('PATCH /api/v1/flows/:flowId/folder', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated flow data of current user', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + active: false, + }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const folder = await createFolder({ + name: 'test', + userId: currentUser.id, + }); + + const response = await request(app) + .patch(`/api/v1/flows/${currentUserFlow.id}/folder`) + .set('Authorization', token) + .send({ folderId: folder.id }) + .expect(200); + + const refetchedFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const expectedPayload = await updateFlowFolderMock(refetchedFlow, folder); + + expect(response.body).toStrictEqual(expectedPayload); + expect(response.body.data.folder.name).toStrictEqual('test'); + }); + + it('should return not found response for other user flows', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}/folder`) + .set('Authorization', token) + .send({ folderId: 12345 }) + .expect(404); + }); + + it('should return not found response for other user folders', async () => { + const flow = await createFlow({ userId: currentUser.id }); + const anotherUser = await createUser(); + const anotherUserFolder = await createFolder({ userId: anotherUser.id }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .patch(`/api/v1/flows/${flow.id}/folder`) + .set('Authorization', token) + .send({ folderId: anotherUserFolder.id }) + .expect(404); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${notExistingFlowUUID}/folder`) + .set('Authorization', token) + .send({ folderId: 12345 }) + .expect(404); + }); + + it('should return not found response for not existing folder UUID', async () => { + const flow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFolderUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${flow.id}/folder`) + .set('Authorization', token) + .send({ folderId: notExistingFolderUUID }) + .expect(404); + }); + + it('should return bad request response for invalid flow UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + await request(app) + .patch('/api/v1/flows/invalidFlowUUID/folder') + .set('Authorization', token) + .expect(400); + }); + + it('should return bad request response for invalid folder UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const flow = await createFlow({ userId: currentUser.id }); + + await request(app) + .patch(`/api/v1/flows/${flow.id}/folder`) + .set('Authorization', token) + .send({ folderId: 'invalidFolderUUID' }) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-status.js b/packages/backend/src/controllers/api/v1/flows/update-flow-status.js new file mode 100644 index 0000000..4bc4fd9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-status.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let flow = await request.currentUser.authorizedFlows + .clone() + .findOne({ + id: request.params.flowId, + }) + .throwIfNotFound(); + + flow = await flow.updateStatus(request.body.active); + + renderObject(response, flow); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js b/packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js new file mode 100644 index 0000000..c36f011 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateFlowStatusMock from '../../../../../test/mocks/rest/api/v1/flows/update-flow-status.js'; + +describe('PATCH /api/v1/flows/:flowId/status', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated flow data of current user', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + active: false, + }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${currentUserFlow.id}/status`) + .set('Authorization', token) + .send({ active: true }) + .expect(200); + + const refetchedFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedFlowSteps = await refetchedFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await updateFlowStatusMock( + refetchedFlow, + refetchedFlowSteps + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(response.body.data.status).toStrictEqual('published'); + }); + + it('should return updated flow data of another user', async () => { + const anotherUser = await createUser(); + + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + active: false, + }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}/status`) + .set('Authorization', token) + .send({ active: true }) + .expect(200); + + const refetchedFlow = await anotherUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedFlowSteps = await refetchedFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await updateFlowStatusMock( + refetchedFlow, + refetchedFlowSteps + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(response.body.data.status).toStrictEqual('published'); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${notExistingFlowUUID}/status`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}/status`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch('/api/v1/flows/invalidFlowUUID/status') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow.js b/packages/backend/src/controllers/api/v1/flows/update-flow.js new file mode 100644 index 0000000..7554d84 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow.js @@ -0,0 +1,15 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findOne({ + id: request.params.flowId, + }) + .throwIfNotFound(); + + await flow.$query().patchAndFetch({ + name: request.body.name, + }); + + renderObject(response, flow); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow.test.js b/packages/backend/src/controllers/api/v1/flows/update-flow.test.js new file mode 100644 index 0000000..9bc7215 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import getFlowMock from '../../../../../test/mocks/rest/api/v1/flows/get-flow.js'; + +describe('PATCH /api/v1/flows/:flowId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the updated flow data of current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${currentUserFlow.id}`) + .set('Authorization', token) + .send({ + name: 'Updated flow', + }) + .expect(200); + + const refetchedCurrentUserFlow = await currentUserFlow.$query(); + + const expectedPayload = await getFlowMock({ + ...refetchedCurrentUserFlow, + name: 'Updated flow', + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the updated flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}`) + .set('Authorization', token) + .send({ + name: 'Updated flow', + }) + .expect(200); + + const refetchedAnotherUserFlow = await anotherUserFlow.$query(); + + const expectedPayload = await getFlowMock({ + ...refetchedAnotherUserFlow, + name: 'Updated flow', + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${notExistingFlowUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch('/api/v1/flows/invalidFlowUUID') + .set('Authorization', token) + .expect(400); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${currentUserFlow.id}`) + .set('Authorization', token) + .send({ + name: 123123, + }) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + name: ['must be string'], + }); + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/folders/create-folder.js b/packages/backend/src/controllers/api/v1/folders/create-folder.js new file mode 100644 index 0000000..4c50a05 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/create-folder.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const folder = await request.currentUser + .$relatedQuery('folders') + .insertAndFetch({ + name: request.body.name, + }); + + renderObject(response, folder, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/folders/create-folder.test.js b/packages/backend/src/controllers/api/v1/folders/create-folder.test.js new file mode 100644 index 0000000..02b3329 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/create-folder.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import createFolderMock from '../../../../../test/mocks/rest/api/v1/folders/create-folder.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('POST /api/v1/folders', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created flow', async () => { + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post('/api/v1/folders') + .set('Authorization', token) + .send({ + name: 'Test Folder', + }) + .expect(201); + + const refetchedFolder = await currentUser + .$relatedQuery('folders') + .findById(response.body.data.id); + + const expectedPayload = await createFolderMock(refetchedFolder); + + expect(response.body).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/folders/delete-folder.js b/packages/backend/src/controllers/api/v1/folders/delete-folder.js new file mode 100644 index 0000000..973a8ef --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/delete-folder.js @@ -0,0 +1,10 @@ +export default async (request, response) => { + const folder = await request.currentUser + .$relatedQuery('folders') + .findById(request.params.folderId) + .throwIfNotFound(); + + await folder.$query().delete(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/folders/delete-folder.test.js b/packages/backend/src/controllers/api/v1/folders/delete-folder.test.js new file mode 100644 index 0000000..c7ea71b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/delete-folder.test.js @@ -0,0 +1,62 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFolder } from '../../../../../test/factories/folder.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('DELETE /api/v1/folders/:folderId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should remove the current user folder and return no content', async () => { + const currentUserFolder = await createFolder({ userId: currentUser.id }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + await request(app) + .delete(`/api/v1/folders/${currentUserFolder.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should return not found response for not existing folder UUID', async () => { + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const notExistingFolderUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/folders/${notExistingFolderUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + await request(app) + .delete('/api/v1/folders/invalidFolderUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/folders/get-folders.js b/packages/backend/src/controllers/api/v1/folders/get-folders.js new file mode 100644 index 0000000..245541d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/get-folders.js @@ -0,0 +1,9 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const folders = await request.currentUser + .$relatedQuery('folders') + .orderBy('name', 'asc'); + + renderObject(response, folders); +}; diff --git a/packages/backend/src/controllers/api/v1/folders/get-folders.test.js b/packages/backend/src/controllers/api/v1/folders/get-folders.test.js new file mode 100644 index 0000000..827f879 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/get-folders.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFolder } from '../../../../../test/factories/folder.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import getFoldersMock from '../../../../../test/mocks/rest/api/v1/folders/get-folders.js'; + +describe('GET /api/v1/folders', () => { + let folderOne, folderTwo, currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + folderOne = await createFolder({ + name: 'Folder One', + userId: currentUser.id, + }); + + folderTwo = await createFolder({ + name: 'Folder Two', + userId: currentUser.id, + }); + + const anotherUser = await createUser(); + + await createFolder({ + name: 'Folder Three', + userId: anotherUser.id, + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return folders of the current user', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const response = await request(app) + .get('/api/v1/folders') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getFoldersMock([folderOne, folderTwo]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/folders/update-folder.js b/packages/backend/src/controllers/api/v1/folders/update-folder.js new file mode 100644 index 0000000..e29656d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/update-folder.js @@ -0,0 +1,16 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const folder = await request.currentUser + .$relatedQuery('folders') + .findOne({ + id: request.params.folderId, + }) + .throwIfNotFound(); + + await folder.$query().patchAndFetch({ + name: request.body.name, + }); + + renderObject(response, folder); +}; diff --git a/packages/backend/src/controllers/api/v1/folders/update-folder.test.js b/packages/backend/src/controllers/api/v1/folders/update-folder.test.js new file mode 100644 index 0000000..f0e3cd9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/folders/update-folder.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFolder } from '../../../../../test/factories/folder.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateFolderMock from '../../../../../test/mocks/rest/api/v1/folders/update-folder.js'; + +describe('PATCH /api/v1/folders/:folderId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the updated folder data of current user', async () => { + const currentUserFolder = await createFolder({ userId: currentUser.id }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const response = await request(app) + .patch(`/api/v1/folders/${currentUserFolder.id}`) + .set('Authorization', token) + .send({ + name: 'Updated folder name', + }) + .expect(200); + + const refetchedCurrentUserFolder = await currentUserFolder.$query(); + + const expectedPayload = await updateFolderMock({ + ...refetchedCurrentUserFolder, + name: 'Updated folder name', + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing folder UUID', async () => { + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const notExistingFolderUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/folders/${notExistingFolderUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + await request(app) + .patch('/api/v1/folders/invalidFolderUUID') + .set('Authorization', token) + .expect(400); + }); + + it('should return unprocessable entity response for invalid data', async () => { + const currentUserFolder = await createFolder({ userId: currentUser.id }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const response = await request(app) + .patch(`/api/v1/folders/${currentUserFolder.id}`) + .set('Authorization', token) + .send({ + name: null, + }) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + name: ['must be string'], + }); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/installation/users/create-user.js b/packages/backend/src/controllers/api/v1/installation/users/create-user.js new file mode 100644 index 0000000..8417231 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/installation/users/create-user.js @@ -0,0 +1,9 @@ +import User from '../../../../../models/user.js'; + +export default async (request, response) => { + const { email, password, fullName } = request.body; + + await User.createAdmin({ email, password, fullName }); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js b/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js new file mode 100644 index 0000000..428437d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/installation/users/create-user.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import Config from '../../../../../models/config.js'; +import User from '../../../../../models/user.js'; +import { createRole } from '../../../../../../test/factories/role'; +import { createUser } from '../../../../../../test/factories/user'; +import { markInstallationCompleted } from '../../../../../../test/factories/config'; + +describe('POST /api/v1/installation/users', () => { + let adminRole; + + beforeEach(async () => { + adminRole = await createRole({ + name: 'Admin', + }); + }); + + describe('for incomplete installations', () => { + it('should respond with HTTP 204 with correct payload when no user', async () => { + expect(await Config.isInstallationCompleted()).toBe(false); + + await request(app) + .post('/api/v1/installation/users') + .send({ + email: 'user@automatisch.io', + password: 'password', + fullName: 'Initial admin', + }) + .expect(204); + + const user = await User.query().findOne({ email: 'user@automatisch.io' }); + + expect(user.roleId).toBe(adminRole.id); + expect(await Config.isInstallationCompleted()).toBe(true); + }); + + it('should respond with HTTP 403 with correct payload when one user exists at least', async () => { + expect(await Config.isInstallationCompleted()).toBe(false); + + await createUser(); + + const usersCountBefore = await User.query().resultSize(); + + await request(app) + .post('/api/v1/installation/users') + .send({ + email: 'user@automatisch.io', + password: 'password', + fullName: 'Initial admin', + }) + .expect(403); + + const usersCountAfter = await User.query().resultSize(); + + expect(usersCountBefore).toStrictEqual(usersCountAfter); + }); + }); + + describe('for completed installations', () => { + beforeEach(async () => { + await markInstallationCompleted(); + }); + + it('should respond with HTTP 403 when installation completed', async () => { + expect(await Config.isInstallationCompleted()).toBe(true); + + await request(app) + .post('/api/v1/installation/users') + .send({ + email: 'user@automatisch.io', + password: 'password', + fullName: 'Initial admin', + }) + .expect(403); + + const user = await User.query().findOne({ email: 'user@automatisch.io' }); + + expect(user).toBeUndefined(); + expect(await Config.isInstallationCompleted()).toBe(true); + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.js b/packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.js new file mode 100644 index 0000000..ca4fefe --- /dev/null +++ b/packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.js @@ -0,0 +1,8 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import Billing from '../../../../helpers/billing/index.ee.js'; + +export default async (request, response) => { + const paddleInfo = Billing.paddleInfo; + + renderObject(response, paddleInfo); +}; diff --git a/packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.test.js b/packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.test.js new file mode 100644 index 0000000..4e81f4d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/payment/get-paddle-info.ee.test.js @@ -0,0 +1,33 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getPaddleInfoMock from '../../../../../test/mocks/rest/api/v1/payment/get-paddle-info.js'; +import appConfig from '../../../../config/app.js'; +import billing from '../../../../helpers/billing/index.ee.js'; + +describe('GET /api/v1/payment/paddle-info', () => { + let user, token; + + beforeEach(async () => { + user = await createUser(); + token = await createAuthTokenByUserId(user.id); + + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + vi.spyOn(billing.paddleInfo, 'vendorId', 'get').mockReturnValue( + 'sampleVendorId' + ); + }); + + it('should return payment plans', async () => { + const response = await request(app) + .get('/api/v1/payment/paddle-info') + .set('Authorization', token) + .expect(200); + + const expectedResponsePayload = await getPaddleInfoMock(); + + expect(response.body).toStrictEqual(expectedResponsePayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/payment/get-plans.ee.js b/packages/backend/src/controllers/api/v1/payment/get-plans.ee.js new file mode 100644 index 0000000..32c0bf8 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/payment/get-plans.ee.js @@ -0,0 +1,8 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import Billing from '../../../../helpers/billing/index.ee.js'; + +export default async (request, response) => { + const paymentPlans = Billing.paddlePlans; + + renderObject(response, paymentPlans); +}; diff --git a/packages/backend/src/controllers/api/v1/payment/get-plans.ee.test.js b/packages/backend/src/controllers/api/v1/payment/get-plans.ee.test.js new file mode 100644 index 0000000..7d953cd --- /dev/null +++ b/packages/backend/src/controllers/api/v1/payment/get-plans.ee.test.js @@ -0,0 +1,29 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getPaymentPlansMock from '../../../../../test/mocks/rest/api/v1/payment/get-plans.js'; +import appConfig from '../../../../config/app.js'; + +describe('GET /api/v1/payment/plans', () => { + let user, token; + + beforeEach(async () => { + user = await createUser(); + token = await createAuthTokenByUserId(user.id); + + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + }); + + it('should return payment plans', async () => { + const response = await request(app) + .get('/api/v1/payment/plans') + .set('Authorization', token) + .expect(200); + + const expectedResponsePayload = await getPaymentPlansMock(); + + expect(response.body).toStrictEqual(expectedResponsePayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.js b/packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.js new file mode 100644 index 0000000..3b06643 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.js @@ -0,0 +1,12 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import SamlAuthProvider from '../../../../models/saml-auth-provider.ee.js'; + +export default async (request, response) => { + const samlAuthProviders = await SamlAuthProvider.query() + .where({ + active: true, + }) + .orderBy('created_at', 'desc'); + + renderObject(response, samlAuthProviders); +}; diff --git a/packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.test.js b/packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.test.js new file mode 100644 index 0000000..faed4f0 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.test.js @@ -0,0 +1,30 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import { createSamlAuthProvider } from '../../../../../test/factories/saml-auth-provider.ee.js'; +import getSamlAuthProvidersMock from '../../../../../test/mocks/rest/api/v1/saml-auth-providers/get-saml-auth-providers.js'; +import * as license from '../../../../helpers/license.ee.js'; + +describe('GET /api/v1/saml-auth-providers', () => { + let samlAuthProviderOne, samlAuthProviderTwo; + + beforeEach(async () => { + samlAuthProviderOne = await createSamlAuthProvider(); + samlAuthProviderTwo = await createSamlAuthProvider(); + }); + + it('should return saml auth providers', async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + const response = await request(app) + .get('/api/v1/saml-auth-providers') + .expect(200); + + const expectedPayload = await getSamlAuthProvidersMock([ + samlAuthProviderTwo, + samlAuthProviderOne, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js new file mode 100644 index 0000000..3bb2d8a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js @@ -0,0 +1,17 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const step = await request.currentUser.authorizedSteps + .clone() + .where('steps.id', request.params.stepId) + .whereNotNull('steps.app_key') + .first() + .throwIfNotFound(); + + const dynamicData = await step.createDynamicData( + request.body.dynamicDataKey, + request.body.parameters + ); + + renderObject(response, dynamicData); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js new file mode 100644 index 0000000..af3f22e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js @@ -0,0 +1,244 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createConnection } from '../../../../../test/factories/connection'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; +import listRepos from '../../../../apps/github/dynamic-data/list-repos/index.js'; +import HttpError from '../../../../errors/http.js'; + +describe('POST /api/v1/steps/:stepId/dynamic-data', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + describe('should return dynamically created data', () => { + let repositories; + + beforeEach(async () => { + repositories = [ + { + value: 'automatisch/automatisch', + name: 'automatisch/automatisch', + }, + { + value: 'automatisch/sample', + name: 'automatisch/sample', + }, + ]; + + vi.spyOn(listRepos, 'run').mockImplementation(async () => { + return { + data: repositories, + }; + }); + }); + + it('of the current users step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const connection = await createConnection({ userId: currentUser.id }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: connection.id, + type: 'action', + appKey: 'github', + key: 'createIssue', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-data`) + .set('Authorization', token) + .send({ + dynamicDataKey: 'listRepos', + parameters: {}, + }) + .expect(200); + + expect(response.body.data).toStrictEqual(repositories); + }); + + it('of the another users step', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const connection = await createConnection({ userId: anotherUser.id }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: connection.id, + type: 'action', + appKey: 'github', + key: 'createIssue', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-data`) + .set('Authorization', token) + .send({ + dynamicDataKey: 'listRepos', + parameters: {}, + }) + .expect(200); + + expect(response.body.data).toStrictEqual(repositories); + }); + }); + + describe('should return error for dynamically created data', () => { + let errors; + + beforeEach(async () => { + errors = { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/rest/users/users#get-a-user', + }; + + vi.spyOn(listRepos, 'run').mockImplementation(async () => { + throw new HttpError({ message: errors }); + }); + }); + + it('of the current users step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const connection = await createConnection({ userId: currentUser.id }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: connection.id, + type: 'action', + appKey: 'github', + key: 'createIssue', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-data`) + .set('Authorization', token) + .send({ + dynamicDataKey: 'listRepos', + parameters: {}, + }) + .expect(422); + + expect(response.body.errors).toStrictEqual(errors); + }); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/steps/${notExistingStepUUID}/dynamic-data`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for existing step UUID without app key', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const step = await createStep({ appKey: null }); + + await request(app) + .post(`/api/v1/steps/${step.id}/dynamic-data`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .post('/api/v1/steps/invalidStepUUID/dynamic-fields') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.js new file mode 100644 index 0000000..f1315df --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.js @@ -0,0 +1,17 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const step = await request.currentUser.authorizedSteps + .clone() + .where('steps.id', request.params.stepId) + .whereNotNull('steps.app_key') + .first() + .throwIfNotFound(); + + const dynamicFields = await step.createDynamicFields( + request.body.dynamicFieldsKey, + request.body.parameters + ); + + renderObject(response, dynamicFields); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.test.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.test.js new file mode 100644 index 0000000..49d7f57 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-fields.test.js @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; +import createDynamicFieldsMock from '../../../../../test/mocks/rest/api/v1/steps/create-dynamic-fields'; + +describe('POST /api/v1/steps/:stepId/dynamic-fields', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return dynamically created fields of the current users step', async () => { + const currentUserflow = await createFlow({ userId: currentUser.id }); + + const actionStep = await createStep({ + flowId: currentUserflow.id, + type: 'action', + appKey: 'slack', + key: 'sendMessageToChannel', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-fields`) + .set('Authorization', token) + .send({ + dynamicFieldsKey: 'listFieldsAfterSendAsBot', + parameters: { + sendAsBot: true, + }, + }) + .expect(200); + + const expectedPayload = await createDynamicFieldsMock(); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return dynamically created fields of the another users step', async () => { + const anotherUser = await createUser(); + const anotherUserflow = await createFlow({ userId: anotherUser.id }); + + const actionStep = await createStep({ + flowId: anotherUserflow.id, + type: 'action', + appKey: 'slack', + key: 'sendMessageToChannel', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-fields`) + .set('Authorization', token) + .send({ + dynamicFieldsKey: 'listFieldsAfterSendAsBot', + parameters: { + sendAsBot: true, + }, + }) + .expect(200); + + const expectedPayload = await createDynamicFieldsMock(); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/steps/${notExistingStepUUID}/dynamic-fields`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for existing step UUID without app key', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const step = await createStep(); + await step.$query().patch({ appKey: null }); + + await request(app) + .post(`/api/v1/steps/${step.id}/dynamic-fields`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .post('/api/v1/steps/invalidStepUUID/dynamic-fields') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/delete-step.js b/packages/backend/src/controllers/api/v1/steps/delete-step.js new file mode 100644 index 0000000..74cfb7b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/delete-step.js @@ -0,0 +1,9 @@ +export default async (request, response) => { + const step = await request.currentUser.authorizedSteps + .findById(request.params.stepId) + .throwIfNotFound(); + + await step.delete(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/delete-step.test.js b/packages/backend/src/controllers/api/v1/steps/delete-step.test.js new file mode 100644 index 0000000..756eb4a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/delete-step.test.js @@ -0,0 +1,134 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createConnection } from '../../../../../test/factories/connection'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; + +describe('DELETE /api/v1/steps/:stepId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should remove the step of the current user and return no content', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const currentUserConnection = await createConnection(); + + await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .delete(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should remove the step of the another user and return no content', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const anotherUserConnection = await createConnection(); + + await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .delete(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/steps/${notExistingStepUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .delete('/api/v1/steps/invalidStepUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/get-connection.js b/packages/backend/src/controllers/api/v1/steps/get-connection.js new file mode 100644 index 0000000..ab1a403 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/get-connection.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const step = await request.currentUser.authorizedSteps + .findById(request.params.stepId) + .throwIfNotFound(); + + const connection = await step.$relatedQuery('connection').throwIfNotFound(); + + renderObject(response, connection); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/get-connection.test.js b/packages/backend/src/controllers/api/v1/steps/get-connection.test.js new file mode 100644 index 0000000..71a49ce --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/get-connection.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createConnection } from '../../../../../test/factories/connection'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; +import getConnectionMock from '../../../../../test/mocks/rest/api/v1/steps/get-connection'; + +describe('GET /api/v1/steps/:stepId/connection', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the current user connection data of specified step', async () => { + const currentUserflow = await createFlow({ userId: currentUser.id }); + + const currentUserConnection = await createConnection(); + const triggerStep = await createStep({ + flowId: currentUserflow.id, + connectionId: currentUserConnection.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/steps/${triggerStep.id}/connection`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getConnectionMock(currentUserConnection); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the current user connection data of specified step', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const anotherUserConnection = await createConnection(); + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/steps/${triggerStep.id}/connection`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getConnectionMock(anotherUserConnection); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing step without connection', async () => { + const stepWithoutConnection = await createStep(); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get(`/api/v1/steps/${stepWithoutConnection.id}/connection`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/steps/${notExistingFlowUUID}/connection`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get('/api/v1/steps/invalidFlowUUID/connection') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/get-previous-steps.js b/packages/backend/src/controllers/api/v1/steps/get-previous-steps.js new file mode 100644 index 0000000..e9e865a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/get-previous-steps.js @@ -0,0 +1,27 @@ +import { ref } from 'objection'; +import ExecutionStep from '../../../../models/execution-step.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const step = await request.currentUser.authorizedSteps + .clone() + .findOne({ 'steps.id': request.params.stepId }) + .throwIfNotFound(); + + const previousSteps = await request.currentUser.authorizedSteps + .clone() + .withGraphJoined('executionSteps') + .where('flow_id', '=', step.flowId) + .andWhere('position', '<', step.position) + .andWhere( + 'executionSteps.created_at', + '=', + ExecutionStep.query() + .max('created_at') + .where('step_id', '=', ref('steps.id')) + .andWhere('status', 'success') + ) + .orderBy('steps.position', 'asc'); + + renderObject(response, previousSteps); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/get-previous-steps.test.js b/packages/backend/src/controllers/api/v1/steps/get-previous-steps.test.js new file mode 100644 index 0000000..b40446e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/get-previous-steps.test.js @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createExecutionStep } from '../../../../../test/factories/execution-step.js'; +import { createPermission } from '../../../../../test/factories/permission'; +import getPreviousStepsMock from '../../../../../test/mocks/rest/api/v1/steps/get-previous-steps'; + +describe('GET /api/v1/steps/:stepId/previous-steps', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the previous steps of the specified step of the current user', async () => { + const currentUserflow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserflow.id, + type: 'trigger', + }); + + const actionStepOne = await createStep({ + flowId: currentUserflow.id, + type: 'action', + }); + + const actionStepTwo = await createStep({ + flowId: currentUserflow.id, + type: 'action', + }); + + const executionStepOne = await createExecutionStep({ + stepId: triggerStep.id, + }); + + const executionStepTwo = await createExecutionStep({ + stepId: actionStepOne.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/steps/${actionStepTwo.id}/previous-steps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getPreviousStepsMock( + [triggerStep, actionStepOne], + [executionStepOne, executionStepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return the previous steps of the specified step of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + }); + + const actionStepOne = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + }); + + const actionStepTwo = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + }); + + const executionStepOne = await createExecutionStep({ + stepId: triggerStep.id, + }); + + const executionStepTwo = await createExecutionStep({ + stepId: actionStepOne.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/steps/${actionStepTwo.id}/previous-steps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getPreviousStepsMock( + [triggerStep, actionStepOne], + [executionStepOne, executionStepTwo] + ); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/steps/${notExistingFlowUUID}/previous-steps`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get('/api/v1/steps/invalidFlowUUID/previous-steps') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/test-step.js b/packages/backend/src/controllers/api/v1/steps/test-step.js new file mode 100644 index 0000000..3b71b97 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/test-step.js @@ -0,0 +1,12 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let step = await request.currentUser.authorizedSteps + .clone() + .findById(request.params.stepId) + .throwIfNotFound(); + + step = await step.test(); + + renderObject(response, step); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/test-step.test.js b/packages/backend/src/controllers/api/v1/steps/test-step.test.js new file mode 100644 index 0000000..b7574e8 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/test-step.test.js @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createConnection } from '../../../../../test/factories/connection'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createExecution } from '../../../../../test/factories/execution.js'; +import { createExecutionStep } from '../../../../../test/factories/execution-step.js'; +import { createPermission } from '../../../../../test/factories/permission'; +import testStepMock from '../../../../../test/mocks/rest/api/v1/steps/test-step.js'; + +describe('POST /api/v1/steps/:stepId/test', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should test the step of the current user and return step data', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const currentUserConnection = await createConnection(); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + appKey: 'webhook', + key: 'catchRawWebhook', + type: 'trigger', + parameters: { + workSynchronously: false, + }, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + appKey: 'formatter', + key: 'text', + type: 'action', + parameters: { + input: `{{step.${triggerStep.id}.body.name}}`, + transform: 'capitalize', + }, + }); + + const execution = await createExecution({ + flowId: currentUserFlow.id, + testRun: true, + }); + + await createExecutionStep({ + dataIn: { workSynchronously: false }, + dataOut: { body: { name: 'john doe' } }, + stepId: triggerStep.id, + executionId: execution.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/test`) + .set('Authorization', token) + .expect(200); + + const expectedLastExecutionStep = await actionStep.$relatedQuery( + 'lastExecutionStep' + ); + + const expectedPayload = await testStepMock( + actionStep, + expectedLastExecutionStep + ); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should test the step of the another user and return step data', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const anotherUserConnection = await createConnection(); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + appKey: 'webhook', + key: 'catchRawWebhook', + type: 'trigger', + parameters: { + workSynchronously: false, + }, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + appKey: 'formatter', + key: 'text', + type: 'action', + parameters: { + input: `{{step.${triggerStep.id}.body.name}}`, + transform: 'capitalize', + }, + }); + + const execution = await createExecution({ + flowId: anotherUserFlow.id, + testRun: true, + }); + + await createExecutionStep({ + dataIn: { workSynchronously: false }, + dataOut: { body: { name: 'john doe' } }, + stepId: triggerStep.id, + executionId: execution.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/test`) + .set('Authorization', token) + .expect(200); + + const expectedLastExecutionStep = await actionStep.$relatedQuery( + 'lastExecutionStep' + ); + + const expectedPayload = await testStepMock( + actionStep, + expectedLastExecutionStep + ); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/steps/${notExistingStepUUID}/test`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .post('/api/v1/steps/invalidStepUUID/test') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.js b/packages/backend/src/controllers/api/v1/steps/update-step.js new file mode 100644 index 0000000..70f0b98 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/update-step.js @@ -0,0 +1,23 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let step = await request.currentUser.authorizedSteps + .findById(request.params.stepId) + .throwIfNotFound(); + + step = await step.updateFor(request.currentUser, stepParams(request)); + + renderObject(response, step); +}; + +const stepParams = (request) => { + const { connectionId, appKey, key, name, parameters } = request.body; + + return { + connectionId, + appKey, + key, + name, + parameters, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.test.js b/packages/backend/src/controllers/api/v1/steps/update-step.test.js new file mode 100644 index 0000000..c219dee --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/update-step.test.js @@ -0,0 +1,213 @@ +import { describe, it, beforeEach, expect } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateStepMock from '../../../../../test/mocks/rest/api/v1/steps/update-step.js'; + +describe('PATCH /api/v1/steps/:stepId', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should update the step of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const currentUserConnection = await createConnection({ + key: 'deepl', + }); + + await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + appKey: 'deepl', + key: 'translateText', + name: 'Translate text', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .send({ + parameters: { + text: 'Hello world!', + targetLanguage: 'de', + name: 'Translate text - Updated step name', + }, + }) + .expect(200); + + const refetchedStep = await actionStep.$query(); + + const expectedResponse = updateStepMock(refetchedStep); + + expect(response.body).toStrictEqual(expectedResponse); + }); + + it('should update the step of the another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const anotherUserConnection = await createConnection({ + key: 'deepl', + }); + + await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + const response = await request(app) + .patch(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .send({ + parameters: { + text: 'Hello world!', + targetLanguage: 'de', + }, + }) + .expect(200); + + const refetchedStep = await actionStep.$query(); + + const expectedResponse = updateStepMock(refetchedStep); + + expect(response.body).toStrictEqual(expectedResponse); + }); + + it('should return not found response for inaccessible connection', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const anotherUser = await createUser(); + const anotherUserConnection = await createConnection({ + key: 'deepl', + userId: anotherUser.id, + }); + + await createStep({ + flowId: currentUserFlow.id, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await request(app) + .patch(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .send({ + connectionId: anotherUserConnection.id, + }) + .expect(404); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/steps/${notExistingStepUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await request(app) + .patch('/api/v1/steps/invalidStepUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/accept-invitation.js b/packages/backend/src/controllers/api/v1/users/accept-invitation.js new file mode 100644 index 0000000..8c6763a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/accept-invitation.js @@ -0,0 +1,21 @@ +import User from '../../../../models/user.js'; + +export default async (request, response) => { + const { token, password } = request.body; + + if (!token) { + throw new Error('Invitation token is required!'); + } + + const user = await User.query() + .findOne({ invitation_token: token }) + .throwIfNotFound(); + + if (!user.isInvitationTokenValid()) { + return response.status(422).end(); + } + + await user.acceptInvitation(password); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/users/delete-current-user.js b/packages/backend/src/controllers/api/v1/users/delete-current-user.js new file mode 100644 index 0000000..9bf5141 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/delete-current-user.js @@ -0,0 +1,5 @@ +export default async (request, response) => { + await request.currentUser.softRemove(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/users/delete-current-user.test.js b/packages/backend/src/controllers/api/v1/users/delete-current-user.test.js new file mode 100644 index 0000000..45b6a1e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/delete-current-user.test.js @@ -0,0 +1,21 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; + +describe('DELETE /api/v1/users/:userId', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should remove user and return 204 no content', async () => { + await request(app) + .delete(`/api/v1/users/${currentUser.id}`) + .set('Authorization', token) + .expect(204); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/forgot-password.js b/packages/backend/src/controllers/api/v1/users/forgot-password.js new file mode 100644 index 0000000..900581c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/forgot-password.js @@ -0,0 +1,13 @@ +import User from '../../../../models/user.js'; + +export default async (request, response) => { + const { email } = request.body; + + const user = await User.query() + .findOne({ email: email.toLowerCase() }) + .throwIfNotFound(); + + await user.sendResetPasswordEmail(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/users/forgot-password.test.js b/packages/backend/src/controllers/api/v1/users/forgot-password.test.js new file mode 100644 index 0000000..4cb441a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/forgot-password.test.js @@ -0,0 +1,30 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import { createUser } from '../../../../../test/factories/user'; + +describe('POST /api/v1/users/forgot-password', () => { + let currentUser; + + beforeEach(async () => { + currentUser = await createUser(); + }); + + it('should respond with no content', async () => { + await request(app) + .post('/api/v1/users/forgot-password') + .send({ + email: currentUser.email, + }) + .expect(204); + }); + + it('should return not found response for not existing user UUID', async () => { + await request(app) + .post('/api/v1/users/forgot-password') + .send({ + email: 'nonexisting@automatisch.io', + }) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/get-apps.js b/packages/backend/src/controllers/api/v1/users/get-apps.js new file mode 100644 index 0000000..94a4ddf --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-apps.js @@ -0,0 +1,7 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const apps = await request.currentUser.getApps(request.query.name); + + renderObject(response, apps, { serializer: 'UserApp' }); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-apps.test.js b/packages/backend/src/controllers/api/v1/users/get-apps.test.js new file mode 100644 index 0000000..c3c7ca7 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-apps.test.js @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createRole } from '../../../../../test/factories/role'; +import { createUser } from '../../../../../test/factories/user'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import getAppsMock from '../../../../../test/mocks/rest/api/v1/users/get-apps.js'; + +describe('GET /api/v1/users/:userId/apps', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUserRole = await createRole(); + currentUser = await createUser({ roleId: currentUserRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return all apps of the current user', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const flowOne = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: flowOne.id, + appKey: 'webhook', + }); + + const flowOneActionStepConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + draft: false, + }); + + await createStep({ + connectionId: flowOneActionStepConnection.id, + flowId: flowOne.id, + appKey: 'deepl', + }); + + const flowTwo = await createFlow({ userId: currentUser.id }); + + const flowTwoTriggerStepConnection = await createConnection({ + userId: currentUser.id, + key: 'github', + draft: false, + }); + + await createStep({ + connectionId: flowTwoTriggerStepConnection.id, + flowId: flowTwo.id, + appKey: 'github', + }); + + await createStep({ + flowId: flowTwo.id, + appKey: 'slack', + }); + + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/apps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return all apps of the another user', async () => { + const anotherUser = await createUser(); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + const flowOne = await createFlow({ userId: anotherUser.id }); + + await createStep({ + flowId: flowOne.id, + appKey: 'webhook', + }); + + const flowOneActionStepConnection = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + draft: false, + }); + + await createStep({ + connectionId: flowOneActionStepConnection.id, + flowId: flowOne.id, + appKey: 'deepl', + }); + + const flowTwo = await createFlow({ userId: anotherUser.id }); + + const flowTwoTriggerStepConnection = await createConnection({ + userId: anotherUser.id, + key: 'github', + draft: false, + }); + + await createStep({ + connectionId: flowTwoTriggerStepConnection.id, + flowId: flowTwo.id, + appKey: 'github', + }); + + await createStep({ + flowId: flowTwo.id, + appKey: 'slack', + }); + + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/apps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(); + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return specified app of the current user', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const flowOne = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: flowOne.id, + appKey: 'webhook', + }); + + const flowOneActionStepConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + draft: false, + }); + + await createStep({ + connectionId: flowOneActionStepConnection.id, + flowId: flowOne.id, + appKey: 'deepl', + }); + + const flowTwo = await createFlow({ userId: currentUser.id }); + + const flowTwoTriggerStepConnection = await createConnection({ + userId: currentUser.id, + key: 'github', + draft: false, + }); + + await createStep({ + connectionId: flowTwoTriggerStepConnection.id, + flowId: flowTwo.id, + appKey: 'github', + }); + + await createStep({ + flowId: flowTwo.id, + appKey: 'slack', + }); + + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/apps?name=deepl`) + .set('Authorization', token) + .expect(200); + + expect(response.body.data.length).toStrictEqual(1); + expect(response.body.data[0].key).toStrictEqual('deepl'); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/get-current-user.js b/packages/backend/src/controllers/api/v1/users/get-current-user.js new file mode 100644 index 0000000..7008168 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-current-user.js @@ -0,0 +1,5 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + renderObject(response, request.currentUser); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-current-user.test.js b/packages/backend/src/controllers/api/v1/users/get-current-user.test.js new file mode 100644 index 0000000..e8f29f2 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-current-user.test.js @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createPermission } from '../../../../../test/factories/permission'; +import { createRole } from '../../../../../test/factories/role'; +import { createUser } from '../../../../../test/factories/user'; +import getCurrentUserMock from '../../../../../test/mocks/rest/api/v1/users/get-current-user'; + +describe('GET /api/v1/users/me', () => { + let role, permissionOne, permissionTwo, currentUser, token; + + beforeEach(async () => { + role = await createRole(); + + permissionOne = await createPermission({ + roleId: role.id, + }); + + permissionTwo = await createPermission({ + roleId: role.id, + }); + + currentUser = await createUser({ + roleId: role.id, + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return current user info', async () => { + const response = await request(app) + .get('/api/v1/users/me') + .set('Authorization', token) + .expect(200); + + const expectedPayload = getCurrentUserMock(currentUser, role, [ + permissionOne, + permissionTwo, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/get-invoices.ee.js b/packages/backend/src/controllers/api/v1/users/get-invoices.ee.js new file mode 100644 index 0000000..ec6e2ef --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-invoices.ee.js @@ -0,0 +1,7 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const invoices = await request.currentUser.getInvoices(); + + renderObject(response, invoices); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-invoices.ee.test.js b/packages/backend/src/controllers/api/v1/users/get-invoices.ee.test.js new file mode 100644 index 0000000..de35453 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-invoices.ee.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import User from '../../../../models/user'; +import getInvoicesMock from '../../../../../test/mocks/rest/api/v1/users/get-invoices.ee'; + +describe('GET /api/v1/user/invoices', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return current user invoices', async () => { + const invoices = [ + { id: 1, amount: 100, description: 'Invoice 1' }, + { id: 2, amount: 200, description: 'Invoice 2' }, + ]; + + vi.spyOn(User.prototype, 'getInvoices').mockResolvedValue(invoices); + + const response = await request(app) + .get('/api/v1/users/invoices') + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getInvoicesMock(invoices); + + expect(response.body).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.js b/packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.js new file mode 100644 index 0000000..bda4c4f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.js @@ -0,0 +1,7 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const planAndUsage = await request.currentUser.getPlanAndUsage(); + + renderObject(response, planAndUsage); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.test.js b/packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.test.js new file mode 100644 index 0000000..a08f3e0 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-plan-and-usage.ee.test.js @@ -0,0 +1,68 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createSubscription } from '../../../../../test/factories/subscription.js'; +import { createUsageData } from '../../../../../test/factories/usage-data.js'; +import appConfig from '../../../../config/app.js'; +import { DateTime } from 'luxon'; + +describe('GET /api/v1/users/:userId/plan-and-usage', () => { + let user, token; + + beforeEach(async () => { + const trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); + user = await createUser({ trialExpiryDate }); + token = await createAuthTokenByUserId(user.id); + + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + }); + + it('should return free trial plan and usage data', async () => { + const response = await request(app) + .get(`/api/v1/users/${user.id}/plan-and-usage`) + .set('Authorization', token) + .expect(200); + + const expectedResponseData = { + plan: { + id: null, + limit: null, + name: 'Free Trial', + }, + usage: { + task: 0, + }, + }; + + expect(response.body.data).toStrictEqual(expectedResponseData); + }); + + it('should return current plan and usage data', async () => { + await createSubscription({ userId: user.id }); + + await createUsageData({ + userId: user.id, + consumedTaskCount: 1234, + }); + + const response = await request(app) + .get(`/api/v1/users/${user.id}/plan-and-usage`) + .set('Authorization', token) + .expect(200); + + const expectedResponseData = { + plan: { + id: '47384', + limit: '10,000', + name: '10k - monthly', + }, + usage: { + task: 1234, + }, + }; + + expect(response.body.data).toStrictEqual(expectedResponseData); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/get-subscription.ee.js b/packages/backend/src/controllers/api/v1/users/get-subscription.ee.js new file mode 100644 index 0000000..afecacc --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-subscription.ee.js @@ -0,0 +1,9 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const subscription = await request.currentUser + .$relatedQuery('currentSubscription') + .throwIfNotFound(); + + renderObject(response, subscription); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-subscription.ee.test.js b/packages/backend/src/controllers/api/v1/users/get-subscription.ee.test.js new file mode 100644 index 0000000..ac9ace6 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-subscription.ee.test.js @@ -0,0 +1,51 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import appConfig from '../../../../config/app.js'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createRole } from '../../../../../test/factories/role'; +import { createUser } from '../../../../../test/factories/user'; +import { createSubscription } from '../../../../../test/factories/subscription.js'; +import getSubscriptionMock from '../../../../../test/mocks/rest/api/v1/users/get-subscription.js'; + +describe('GET /api/v1/users/:userId/subscription', () => { + let currentUser, role, subscription, token; + + beforeEach(async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + + role = await createRole(); + + currentUser = await createUser({ + roleId: role.id, + }); + + subscription = await createSubscription({ userId: currentUser.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return subscription info of the current user', async () => { + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/subscription`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getSubscriptionMock(subscription); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response if there is no current subscription', async () => { + const userWithoutSubscription = await createUser({ + roleId: role.id, + }); + + const token = await createAuthTokenByUserId(userWithoutSubscription.id); + + await request(app) + .get(`/api/v1/users/${userWithoutSubscription.id}/subscription`) + .set('Authorization', token) + .expect(404); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/get-user-trial.ee.js b/packages/backend/src/controllers/api/v1/users/get-user-trial.ee.js new file mode 100644 index 0000000..1c4575b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-user-trial.ee.js @@ -0,0 +1,12 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const inTrial = await request.currentUser.inTrial(); + + const trialInfo = { + inTrial, + expireAt: request.currentUser.trialExpiryDate, + }; + + renderObject(response, trialInfo); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-user-trial.ee.test.js b/packages/backend/src/controllers/api/v1/users/get-user-trial.ee.test.js new file mode 100644 index 0000000..7f6f7e9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-user-trial.ee.test.js @@ -0,0 +1,38 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import getUserTrialMock from '../../../../../test/mocks/rest/api/v1/users/get-user-trial.js'; +import appConfig from '../../../../config/app.js'; +import { DateTime } from 'luxon'; +import User from '../../../../models/user.js'; + +describe('GET /api/v1/users/:userId/trial', () => { + let user, token; + + beforeEach(async () => { + const trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); + user = await createUser({ trialExpiryDate }); + token = await createAuthTokenByUserId(user.id); + + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + }); + + describe('should return in trial, active subscription and expire at info', () => { + beforeEach(async () => { + vi.spyOn(User.prototype, 'inTrial').mockResolvedValue(false); + vi.spyOn(User.prototype, 'hasActiveSubscription').mockResolvedValue(true); + }); + + it('should return null', async () => { + const response = await request(app) + .get(`/api/v1/users/${user.id}/trial`) + .set('Authorization', token) + .expect(200); + + const expectedResponsePayload = await getUserTrialMock(user); + expect(response.body).toStrictEqual(expectedResponsePayload); + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/register-user.ee.js b/packages/backend/src/controllers/api/v1/users/register-user.ee.js new file mode 100644 index 0000000..6ab5402 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/register-user.ee.js @@ -0,0 +1,18 @@ +import User from '../../../../models/user.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const user = await User.registerUser(userParams(request)); + + renderObject(response, user, { status: 201 }); +}; + +const userParams = (request) => { + const { fullName, email, password } = request.body; + + return { + fullName, + email, + password, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/users/register-user.ee.test.js b/packages/backend/src/controllers/api/v1/users/register-user.ee.test.js new file mode 100644 index 0000000..5220efd --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/register-user.ee.test.js @@ -0,0 +1,96 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import User from '../../../../models/user.js'; +import appConfig from '../../../../config/app.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createRole } from '../../../../../test/factories/role.js'; +import registerUserMock from '../../../../../test/mocks/rest/api/v1/users/register-user.ee.js'; + +describe('POST /api/v1/users/register', () => { + beforeEach(async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + }); + + it('should return registered user with valid data', async () => { + await createRole({ name: 'User' }); + + const userData = { + email: 'registered@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const expectedPayload = registerUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response without user role existing', async () => { + const userData = { + email: 'registered@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(404); + }); + + it('should return unprocessable entity response with already used email', async () => { + await createRole({ name: 'User' }); + await createUser({ + email: 'registered@sample.com', + }); + + const userData = { + email: 'registered@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + email: ["'email' must be unique."], + }); + + expect(response.body.meta).toStrictEqual({ + type: 'UniqueViolationError', + }); + }); + + it('should return unprocessable entity response with invalid user data', async () => { + await createRole({ name: 'User' }); + + const userData = { + email: null, + fullName: null, + }; + + const response = await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toStrictEqual({ + email: ['must be string'], + fullName: ['must be string'], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/reset-password.js b/packages/backend/src/controllers/api/v1/users/reset-password.js new file mode 100644 index 0000000..3044e7b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/reset-password.js @@ -0,0 +1,23 @@ +import User from '../../../../models/user.js'; +import { renderError } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const { token, password } = request.body; + + const user = await User.query() + .findOne({ + reset_password_token: token, + }) + .throwIfNotFound(); + + if (!user.isResetPasswordTokenValid()) { + return renderError(response, [{ general: [invalidTokenErrorMessage] }]); + } + + await user.resetPassword(password); + + response.status(204).end(); +}; + +const invalidTokenErrorMessage = + 'Reset password link is not valid or expired. Try generating a new link.'; diff --git a/packages/backend/src/controllers/api/v1/users/reset-password.test.js b/packages/backend/src/controllers/api/v1/users/reset-password.test.js new file mode 100644 index 0000000..e36342d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/reset-password.test.js @@ -0,0 +1,49 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import { DateTime } from 'luxon'; +import app from '../../../../app.js'; +import { createUser } from '../../../../../test/factories/user'; + +describe('POST /api/v1/users/reset-password', () => { + let currentUser; + + beforeEach(async () => { + currentUser = await createUser({ + resetPasswordToken: 'sampleResetPasswordToken', + resetPasswordTokenSentAt: DateTime.now().toISO(), + }); + }); + + it('should respond with no content', async () => { + await request(app) + .post('/api/v1/users/reset-password') + .send({ + token: currentUser.resetPasswordToken, + password: 'newPassword', + }) + .expect(204); + }); + + it('should return not found response for not existing user', async () => { + await request(app) + .post('/api/v1/users/reset-password') + .send({ + token: 'nonExistingResetPasswordToken', + }) + .expect(404); + }); + + it('should return unprocessable entity for existing user with expired reset password token', async () => { + const user = await createUser({ + resetPasswordToken: 'anotherResetPasswordToken', + resetPasswordTokenSentAt: DateTime.now().minus({ days: 2 }).toISO(), + }); + + await request(app) + .post('/api/v1/users/reset-password') + .send({ + token: user.resetPasswordToken, + }) + .expect(422); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/update-current-user-password.js b/packages/backend/src/controllers/api/v1/users/update-current-user-password.js new file mode 100644 index 0000000..982fe89 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/update-current-user-password.js @@ -0,0 +1,12 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const user = await request.currentUser.updatePassword(userParams(request)); + + renderObject(response, user); +}; + +const userParams = (request) => { + const { currentPassword, password } = request.body; + return { currentPassword, password }; +}; diff --git a/packages/backend/src/controllers/api/v1/users/update-current-user-password.test.js b/packages/backend/src/controllers/api/v1/users/update-current-user-password.test.js new file mode 100644 index 0000000..ddb4a0d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/update-current-user-password.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import updateCurrentUserPasswordMock from '../../../../../test/mocks/rest/api/v1/users/update-current-user-password.js'; + +describe('PATCH /api/v1/users/:userId/password', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser({ password: 'old-password' }); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated user with valid password', async () => { + const userData = { + currentPassword: 'old-password', + password: 'new-password', + }; + + const response = await request(app) + .patch(`/api/v1/users/${currentUser.id}/password`) + .set('Authorization', token) + .send(userData) + .expect(200); + + const refetchedCurrentUser = await currentUser.$query(); + const expectedPayload = updateCurrentUserPasswordMock(refetchedCurrentUser); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return HTTP 422 with invalid current password', async () => { + const userData = { + currentPassword: '', + password: 'new-password', + }; + + const response = await request(app) + .patch(`/api/v1/users/${currentUser.id}/password`) + .set('Authorization', token) + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ValidationError'); + expect(response.body.errors).toMatchObject({ + currentPassword: ['is incorrect.'], + }); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/users/update-current-user.js b/packages/backend/src/controllers/api/v1/users/update-current-user.js new file mode 100644 index 0000000..8ff5a5f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/update-current-user.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const user = await request.currentUser + .$query() + .patchAndFetch(userParams(request)); + + renderObject(response, user); +}; + +const userParams = (request) => { + const { email, fullName } = request.body; + return { email, fullName }; +}; diff --git a/packages/backend/src/controllers/api/v1/users/update-current-user.test.js b/packages/backend/src/controllers/api/v1/users/update-current-user.test.js new file mode 100644 index 0000000..38a56a8 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/update-current-user.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import updateCurrentUserMock from '../../../../../test/mocks/rest/api/v1/users/update-current-user.js'; + +describe('PATCH /api/v1/users/:userId', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated user with valid data', async () => { + const userData = { + email: 'updated@sample.com', + fullName: 'Updated Full Name', + }; + + const response = await request(app) + .patch(`/api/v1/users/${currentUser.id}`) + .set('Authorization', token) + .send(userData) + .expect(200); + + const refetchedCurrentUser = await currentUser.$query(); + + const expectedPayload = updateCurrentUserMock({ + ...refetchedCurrentUser, + ...userData, + }); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should return HTTP 422 with invalid user data', async () => { + const userData = { + email: null, + fullName: null, + }; + + const response = await request(app) + .patch(`/api/v1/users/${currentUser.id}`) + .set('Authorization', token) + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + + expect(response.body.errors).toMatchObject({ + email: ['must be string'], + fullName: ['must be string'], + }); + }); +}); diff --git a/packages/backend/src/controllers/healthcheck/index.js b/packages/backend/src/controllers/healthcheck/index.js new file mode 100644 index 0000000..6305ab5 --- /dev/null +++ b/packages/backend/src/controllers/healthcheck/index.js @@ -0,0 +1,3 @@ +export default async (request, response) => { + response.status(200).end(); +}; diff --git a/packages/backend/src/controllers/healthcheck/index.test.js b/packages/backend/src/controllers/healthcheck/index.test.js new file mode 100644 index 0000000..feb4928 --- /dev/null +++ b/packages/backend/src/controllers/healthcheck/index.test.js @@ -0,0 +1,9 @@ +import { describe, it } from 'vitest'; +import request from 'supertest'; +import app from '../../app.js'; + +describe('GET /healthcheck', () => { + it('should return 200 response with version data', async () => { + await request(app).get('/healthcheck').expect(200); + }); +}); diff --git a/packages/backend/src/controllers/paddle/webhooks.ee.js b/packages/backend/src/controllers/paddle/webhooks.ee.js new file mode 100644 index 0000000..b2d3e50 --- /dev/null +++ b/packages/backend/src/controllers/paddle/webhooks.ee.js @@ -0,0 +1,47 @@ +import crypto from 'crypto'; +import { serialize } from 'php-serialize'; +import Billing from '../../helpers/billing/index.ee.js'; +import appConfig from '../../config/app.js'; + +export default async (request, response) => { + if (!verifyWebhook(request)) { + return response.sendStatus(401); + } + + if (request.body.alert_name === 'subscription_created') { + await Billing.webhooks.handleSubscriptionCreated(request); + } else if (request.body.alert_name === 'subscription_updated') { + await Billing.webhooks.handleSubscriptionUpdated(request); + } else if (request.body.alert_name === 'subscription_cancelled') { + await Billing.webhooks.handleSubscriptionCancelled(request); + } else if (request.body.alert_name === 'subscription_payment_succeeded') { + await Billing.webhooks.handleSubscriptionPaymentSucceeded(request); + } + + return response.sendStatus(200); +}; + +const verifyWebhook = (request) => { + const signature = request.body.p_signature; + + const keys = Object.keys(request.body) + .filter((key) => key !== 'p_signature') + .sort(); + + const sorted = {}; + keys.forEach((key) => { + sorted[key] = request.body[key]; + }); + + const serialized = serialize(sorted); + + try { + const verifier = crypto.createVerify('sha1'); + verifier.write(serialized); + verifier.end(); + + return verifier.verify(appConfig.paddlePublicKey, signature, 'base64'); + } catch (err) { + return false; + } +}; diff --git a/packages/backend/src/controllers/webhooks/handler-by-flow-id.js b/packages/backend/src/controllers/webhooks/handler-by-flow-id.js new file mode 100644 index 0000000..5ed0fd6 --- /dev/null +++ b/packages/backend/src/controllers/webhooks/handler-by-flow-id.js @@ -0,0 +1,31 @@ +import Flow from '../../models/flow.js'; +import logger from '../../helpers/logger.js'; +import handler from '../../helpers/webhook-handler.js'; + +export default async (request, response) => { + const computedRequestPayload = { + headers: request.headers, + body: request.body, + query: request.query, + params: request.params, + }; + + logger.debug(`Handling incoming webhook request at ${request.originalUrl}.`); + logger.debug(JSON.stringify(computedRequestPayload, null, 2)); + + const flowId = request.params.flowId; + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const triggerStep = await flow.getTriggerStep(); + + if (triggerStep.appKey !== 'webhook') { + const connection = await triggerStep.$relatedQuery('connection'); + + if (!(await connection.verifyWebhook(request))) { + return response.sendStatus(401); + } + } + + await handler(flowId, request, response); + + response.sendStatus(204); +}; diff --git a/packages/backend/src/controllers/webhooks/handler-sync-by-flow-id.js b/packages/backend/src/controllers/webhooks/handler-sync-by-flow-id.js new file mode 100644 index 0000000..64c934e --- /dev/null +++ b/packages/backend/src/controllers/webhooks/handler-sync-by-flow-id.js @@ -0,0 +1,29 @@ +import Flow from '../../models/flow.js'; +import logger from '../../helpers/logger.js'; +import handlerSync from '../../helpers/webhook-handler-sync.js'; + +export default async (request, response) => { + const computedRequestPayload = { + headers: request.headers, + body: request.body, + query: request.query, + params: request.params, + }; + + logger.debug(`Handling incoming webhook request at ${request.originalUrl}.`); + logger.debug(JSON.stringify(computedRequestPayload, null, 2)); + + const flowId = request.params.flowId; + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const triggerStep = await flow.getTriggerStep(); + + if (triggerStep.appKey !== 'webhook') { + const connection = await triggerStep.$relatedQuery('connection'); + + if (!(await connection.verifyWebhook(request))) { + return response.sendStatus(401); + } + } + + await handlerSync(flowId, request, response); +}; diff --git a/packages/backend/src/db/migrations/20211005151457_create_users.js b/packages/backend/src/db/migrations/20211005151457_create_users.js new file mode 100644 index 0000000..b1699c9 --- /dev/null +++ b/packages/backend/src/db/migrations/20211005151457_create_users.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.createTable('users', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('email').unique().notNullable(); + table.string('password').notNullable(); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('users'); +} diff --git a/packages/backend/src/db/migrations/20211011120732_create_credentials.js b/packages/backend/src/db/migrations/20211011120732_create_credentials.js new file mode 100644 index 0000000..842f5ea --- /dev/null +++ b/packages/backend/src/db/migrations/20211011120732_create_credentials.js @@ -0,0 +1,16 @@ +export async function up(knex) { + return knex.schema.createTable('credentials', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').notNullable(); + table.string('display_name').notNullable(); + table.text('data').notNullable(); + table.uuid('user_id').references('id').inTable('users'); + table.boolean('verified').defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('credentials'); +} diff --git a/packages/backend/src/db/migrations/20211014144855_remove_display_name_from_credentials.js b/packages/backend/src/db/migrations/20211014144855_remove_display_name_from_credentials.js new file mode 100644 index 0000000..5e746de --- /dev/null +++ b/packages/backend/src/db/migrations/20211014144855_remove_display_name_from_credentials.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('credentials', (table) => { + table.dropColumn('display_name'); + }); +} + +export async function down(knex) { + return knex.schema.table('credentials', (table) => { + table.string('display_name'); + }); +} diff --git a/packages/backend/src/db/migrations/20211017104154_rename_credentials_as_connections.js b/packages/backend/src/db/migrations/20211017104154_rename_credentials_as_connections.js new file mode 100644 index 0000000..78f0740 --- /dev/null +++ b/packages/backend/src/db/migrations/20211017104154_rename_credentials_as_connections.js @@ -0,0 +1,7 @@ +export async function up(knex) { + return knex.schema.renameTable('credentials', 'connections'); +} + +export async function down(knex) { + return knex.schema.renameTable('connections', 'credentials'); +} diff --git a/packages/backend/src/db/migrations/20211106214730_create_steps.js b/packages/backend/src/db/migrations/20211106214730_create_steps.js new file mode 100644 index 0000000..c84a1d3 --- /dev/null +++ b/packages/backend/src/db/migrations/20211106214730_create_steps.js @@ -0,0 +1,16 @@ +export async function up(knex) { + return knex.schema.createTable('steps', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').notNullable(); + table.string('app_key').notNullable(); + table.string('type').notNullable(); + table.uuid('connection_id').references('id').inTable('connections'); + table.text('parameters'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('steps'); +} diff --git a/packages/backend/src/db/migrations/20211122140336_create_flows.js b/packages/backend/src/db/migrations/20211122140336_create_flows.js new file mode 100644 index 0000000..00e81c9 --- /dev/null +++ b/packages/backend/src/db/migrations/20211122140336_create_flows.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.createTable('flows', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name'); + table.uuid('user_id').references('id').inTable('users'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('flows'); +} diff --git a/packages/backend/src/db/migrations/20211122140612_add_flow_id_to_steps.js b/packages/backend/src/db/migrations/20211122140612_add_flow_id_to_steps.js new file mode 100644 index 0000000..e1326cc --- /dev/null +++ b/packages/backend/src/db/migrations/20211122140612_add_flow_id_to_steps.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('steps', (table) => { + table.uuid('flow_id').references('id').inTable('flows'); + }); +} + +export async function down(knex) { + return knex.schema.table('steps', (table) => { + table.dropColumn('flow_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20220105151725_remove_constraints_from_steps.js b/packages/backend/src/db/migrations/20220105151725_remove_constraints_from_steps.js new file mode 100644 index 0000000..bffa24f --- /dev/null +++ b/packages/backend/src/db/migrations/20220105151725_remove_constraints_from_steps.js @@ -0,0 +1,15 @@ +export async function up(knex) { + return knex.schema.alterTable('steps', (table) => { + table.string('key').nullable().alter(); + table.string('app_key').nullable().alter(); + }); +} + +export async function down() { + // We can't use down migration here since there are null values which needs to be set! + // We don't want to set those values by default key and app key since it will mislead users. + // return knex.schema.alterTable('steps', (table) => { + // table.string('key').notNullable().alter(); + // table.string('app_key').notNullable().alter(); + // }); +} diff --git a/packages/backend/src/db/migrations/20220108141045_add_active_column_to_flows.js b/packages/backend/src/db/migrations/20220108141045_add_active_column_to_flows.js new file mode 100644 index 0000000..5da6273 --- /dev/null +++ b/packages/backend/src/db/migrations/20220108141045_add_active_column_to_flows.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('flows', (table) => { + table.boolean('active').defaultTo(false); + }); +} + +export async function down(knex) { + return knex.schema.table('flows', (table) => { + table.dropColumn('active'); + }); +} diff --git a/packages/backend/src/db/migrations/20220127141941_add_position_to_steps.js b/packages/backend/src/db/migrations/20220127141941_add_position_to_steps.js new file mode 100644 index 0000000..bf1f567 --- /dev/null +++ b/packages/backend/src/db/migrations/20220127141941_add_position_to_steps.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('steps', (table) => { + table.integer('position').notNullable(); + }); +} + +export async function down(knex) { + return knex.schema.table('steps', (table) => { + table.dropColumn('position'); + }); +} diff --git a/packages/backend/src/db/migrations/20220205145128_add_status_to_steps.js b/packages/backend/src/db/migrations/20220205145128_add_status_to_steps.js new file mode 100644 index 0000000..b7629f7 --- /dev/null +++ b/packages/backend/src/db/migrations/20220205145128_add_status_to_steps.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('steps', (table) => { + table.string('status').notNullable().defaultTo('incomplete'); + }); +} + +export async function down(knex) { + return knex.schema.table('steps', (table) => { + table.dropColumn('status'); + }); +} diff --git a/packages/backend/src/db/migrations/20220219093113_create_executions.js b/packages/backend/src/db/migrations/20220219093113_create_executions.js new file mode 100644 index 0000000..1ce2e5c --- /dev/null +++ b/packages/backend/src/db/migrations/20220219093113_create_executions.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.createTable('executions', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('flow_id').references('id').inTable('flows'); + table.boolean('test_run').notNullable().defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('executions'); +} diff --git a/packages/backend/src/db/migrations/20220219100800_create_execution_steps.js b/packages/backend/src/db/migrations/20220219100800_create_execution_steps.js new file mode 100644 index 0000000..9fe4520 --- /dev/null +++ b/packages/backend/src/db/migrations/20220219100800_create_execution_steps.js @@ -0,0 +1,16 @@ +export async function up(knex) { + return knex.schema.createTable('execution_steps', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('execution_id').references('id').inTable('executions'); + table.uuid('step_id').references('id').inTable('steps'); + table.string('status'); + table.text('data_in'); + table.text('data_out'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('execution_steps'); +} diff --git a/packages/backend/src/db/migrations/20220221225128_alter_columns_of_execution_steps.js b/packages/backend/src/db/migrations/20220221225128_alter_columns_of_execution_steps.js new file mode 100644 index 0000000..04bc60d --- /dev/null +++ b/packages/backend/src/db/migrations/20220221225128_alter_columns_of_execution_steps.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.alterTable('execution_steps', (table) => { + table.jsonb('data_in').alter(); + table.jsonb('data_out').alter(); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('execution_steps', (table) => { + table.text('data_in').alter(); + table.text('data_out').alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20220221225537_alter_parameters_column_of_steps.js b/packages/backend/src/db/migrations/20220221225537_alter_parameters_column_of_steps.js new file mode 100644 index 0000000..6bfd231 --- /dev/null +++ b/packages/backend/src/db/migrations/20220221225537_alter_parameters_column_of_steps.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.alterTable('steps', (table) => { + table.jsonb('parameters').defaultTo('{}').alter(); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('steps', (table) => { + table.text('parameters').alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20220727104324_add_draft_column_to_connections.js b/packages/backend/src/db/migrations/20220727104324_add_draft_column_to_connections.js new file mode 100644 index 0000000..12f8e8d --- /dev/null +++ b/packages/backend/src/db/migrations/20220727104324_add_draft_column_to_connections.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('connections', (table) => { + table.boolean('draft').defaultTo(true); + }); +} + +export async function down(knex) { + return knex.schema.table('connections', (table) => { + table.dropColumn('draft'); + }); +} diff --git a/packages/backend/src/db/migrations/20220817121241_add_published_at_column_to_flows.js b/packages/backend/src/db/migrations/20220817121241_add_published_at_column_to_flows.js new file mode 100644 index 0000000..fd436f3 --- /dev/null +++ b/packages/backend/src/db/migrations/20220817121241_add_published_at_column_to_flows.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('flows', (table) => { + table.timestamp('published_at').nullable(); + }); +} + +export async function down(knex) { + return knex.schema.table('flows', (table) => { + table.dropColumn('published_at'); + }); +} diff --git a/packages/backend/src/db/migrations/20220823171017_add_internal_id_to_executions.js b/packages/backend/src/db/migrations/20220823171017_add_internal_id_to_executions.js new file mode 100644 index 0000000..a833571 --- /dev/null +++ b/packages/backend/src/db/migrations/20220823171017_add_internal_id_to_executions.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('executions', (table) => { + table.string('internal_id'); + }); +} + +export async function down(knex) { + return knex.schema.table('executions', (table) => { + table.dropColumn('internal_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20220904160521_add_raw_error_to_execution_steps.js b/packages/backend/src/db/migrations/20220904160521_add_raw_error_to_execution_steps.js new file mode 100644 index 0000000..ccef7ec --- /dev/null +++ b/packages/backend/src/db/migrations/20220904160521_add_raw_error_to_execution_steps.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('execution_steps', (table) => { + table.jsonb('error_details'); + }); +} + +export async function down(knex) { + return knex.schema.table('execution_steps', (table) => { + table.dropColumn('error_details'); + }); +} diff --git a/packages/backend/src/db/migrations/20220928162525_soft-delete-base-model.js b/packages/backend/src/db/migrations/20220928162525_soft-delete-base-model.js new file mode 100644 index 0000000..b584d6a --- /dev/null +++ b/packages/backend/src/db/migrations/20220928162525_soft-delete-base-model.js @@ -0,0 +1,29 @@ +async function addDeletedColumn(knex, tableName) { + return await knex.schema.table(tableName, (table) => { + table.timestamp('deleted_at').nullable(); + }); +} + +async function dropDeletedColumn(knex, tableName) { + return await knex.schema.table(tableName, (table) => { + table.dropColumn('deleted_at'); + }); +} + +export async function up(knex) { + await addDeletedColumn(knex, 'steps'); + await addDeletedColumn(knex, 'flows'); + await addDeletedColumn(knex, 'executions'); + await addDeletedColumn(knex, 'execution_steps'); + await addDeletedColumn(knex, 'users'); + await addDeletedColumn(knex, 'connections'); +} + +export async function down(knex) { + await dropDeletedColumn(knex, 'steps'); + await dropDeletedColumn(knex, 'flows'); + await dropDeletedColumn(knex, 'executions'); + await dropDeletedColumn(knex, 'execution_steps'); + await dropDeletedColumn(knex, 'users'); + await dropDeletedColumn(knex, 'connections'); +} diff --git a/packages/backend/src/db/migrations/20221214184855_add_remote_webhook_id_in_flow.js b/packages/backend/src/db/migrations/20221214184855_add_remote_webhook_id_in_flow.js new file mode 100644 index 0000000..3a39da2 --- /dev/null +++ b/packages/backend/src/db/migrations/20221214184855_add_remote_webhook_id_in_flow.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('flows', (table) => { + table.string('remote_webhook_id'); + }); +} + +export async function down(knex) { + return knex.schema.table('flows', (table) => { + table.dropColumn('remote_webhook_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20230218110748_add_role_to_users.js b/packages/backend/src/db/migrations/20230218110748_add_role_to_users.js new file mode 100644 index 0000000..c4b1af6 --- /dev/null +++ b/packages/backend/src/db/migrations/20230218110748_add_role_to_users.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.table('users', async (table) => { + table.string('role'); + + await knex('users').update({ role: 'admin' }); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('role'); + }); +} diff --git a/packages/backend/src/db/migrations/20230218131824_alter_role_to_not_nullable_for_users.js b/packages/backend/src/db/migrations/20230218131824_alter_role_to_not_nullable_for_users.js new file mode 100644 index 0000000..90e4e30 --- /dev/null +++ b/packages/backend/src/db/migrations/20230218131824_alter_role_to_not_nullable_for_users.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.alterTable('users', (table) => { + table.string('role').notNullable().alter(); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('users', (table) => { + table.string('role').nullable().alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20230218150517_add_reset_password_token_to_users.js b/packages/backend/src/db/migrations/20230218150517_add_reset_password_token_to_users.js new file mode 100644 index 0000000..ddd5bbc --- /dev/null +++ b/packages/backend/src/db/migrations/20230218150517_add_reset_password_token_to_users.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('users', (table) => { + table.string('reset_password_token'); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('reset_password_token'); + }); +} diff --git a/packages/backend/src/db/migrations/20230218150758_add_reset_password_token_sent_at_to_users.js b/packages/backend/src/db/migrations/20230218150758_add_reset_password_token_sent_at_to_users.js new file mode 100644 index 0000000..7373517 --- /dev/null +++ b/packages/backend/src/db/migrations/20230218150758_add_reset_password_token_sent_at_to_users.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('users', (table) => { + table.timestamp('reset_password_token_sent_at'); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('reset_password_token_sent_at'); + }); +} diff --git a/packages/backend/src/db/migrations/20230301211751_add_full_name_to_users.js b/packages/backend/src/db/migrations/20230301211751_add_full_name_to_users.js new file mode 100644 index 0000000..277e681 --- /dev/null +++ b/packages/backend/src/db/migrations/20230301211751_add_full_name_to_users.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.table('users', async (table) => { + table.string('full_name'); + + await knex('users').update({ full_name: 'Initial admin' }); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('full_name'); + }); +} diff --git a/packages/backend/src/db/migrations/20230303134548_create_payment_plans.js b/packages/backend/src/db/migrations/20230303134548_create_payment_plans.js new file mode 100644 index 0000000..4ebe181 --- /dev/null +++ b/packages/backend/src/db/migrations/20230303134548_create_payment_plans.js @@ -0,0 +1,23 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.createTable('payment_plans', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').notNullable(); + table.integer('task_count').notNullable(); + table.uuid('user_id').references('id').inTable('users'); + table.string('stripe_customer_id'); + table.string('stripe_subscription_id'); + table.timestamp('current_period_started_at').nullable(); + table.timestamp('current_period_ends_at').nullable(); + table.timestamp('deleted_at').nullable(); + table.timestamps(true, true); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + return knex.schema.dropTable('payment_plans'); +} diff --git a/packages/backend/src/db/migrations/20230303180902_create_usage_data.js b/packages/backend/src/db/migrations/20230303180902_create_usage_data.js new file mode 100644 index 0000000..953423d --- /dev/null +++ b/packages/backend/src/db/migrations/20230303180902_create_usage_data.js @@ -0,0 +1,19 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.createTable('usage_data', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users'); + table.string('consumed_task_count').notNullable(); + table.timestamp('next_reset_at').nullable(); + table.timestamp('deleted_at').nullable(); + table.timestamps(true, true); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + return knex.schema.dropTable('usage_data'); +} diff --git a/packages/backend/src/db/migrations/20230306103149_alter_consumed_task_count_of_usage_data.js b/packages/backend/src/db/migrations/20230306103149_alter_consumed_task_count_of_usage_data.js new file mode 100644 index 0000000..7a402b3 --- /dev/null +++ b/packages/backend/src/db/migrations/20230306103149_alter_consumed_task_count_of_usage_data.js @@ -0,0 +1,17 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.alterTable('usage_data', (table) => { + table.integer('consumed_task_count').notNullable().alter(); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.alterTable('usage_data', (table) => { + table.string('consumed_task_count').notNullable().alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20230318220822_add_trial_expiry_date_to_users.js b/packages/backend/src/db/migrations/20230318220822_add_trial_expiry_date_to_users.js new file mode 100644 index 0000000..0fb35e1 --- /dev/null +++ b/packages/backend/src/db/migrations/20230318220822_add_trial_expiry_date_to_users.js @@ -0,0 +1,17 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.table('users', (table) => { + table.date('trial_expiry_date'); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.table('users', (table) => { + table.dropColumn('trial_expiry_date'); + }); +} diff --git a/packages/backend/src/db/migrations/20230323145809_create_subscriptions.js b/packages/backend/src/db/migrations/20230323145809_create_subscriptions.js new file mode 100644 index 0000000..1d8ce06 --- /dev/null +++ b/packages/backend/src/db/migrations/20230323145809_create_subscriptions.js @@ -0,0 +1,26 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.createTable('subscriptions', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users'); + table.string('paddle_subscription_id').unique().notNullable(); + table.string('paddle_plan_id').notNullable(); + table.string('update_url').notNullable(); + table.string('cancel_url').notNullable(); + table.string('status').notNullable(); + table.string('next_bill_amount').notNullable(); + table.date('next_bill_date').notNullable(); + table.date('last_bill_date'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.dropTable('subscriptions'); +} diff --git a/packages/backend/src/db/migrations/20230324210051_add_deleted_at_to_subscriptions.js b/packages/backend/src/db/migrations/20230324210051_add_deleted_at_to_subscriptions.js new file mode 100644 index 0000000..28a7d26 --- /dev/null +++ b/packages/backend/src/db/migrations/20230324210051_add_deleted_at_to_subscriptions.js @@ -0,0 +1,17 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.alterTable('subscriptions', (table) => { + table.timestamp('deleted_at').nullable(); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.alterTable('subscriptions', (table) => { + table.dropColumn('deleted_at'); + }); +} diff --git a/packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.js b/packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.js new file mode 100644 index 0000000..d0ba0cb --- /dev/null +++ b/packages/backend/src/db/migrations/20230402183738_add_subscription_id_in_usage_data.js @@ -0,0 +1,17 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.table('usage_data', (table) => { + table.uuid('subscription_id').references('id').inTable('subscriptions'); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.table('usage_data', (table) => { + table.dropColumn('subscription_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20230411203412_add_cancellation_effective_date_to_subscriptions.js b/packages/backend/src/db/migrations/20230411203412_add_cancellation_effective_date_to_subscriptions.js new file mode 100644 index 0000000..f7457b8 --- /dev/null +++ b/packages/backend/src/db/migrations/20230411203412_add_cancellation_effective_date_to_subscriptions.js @@ -0,0 +1,17 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.table('subscriptions', (table) => { + table.date('cancellation_effective_date'); + }); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.table('subscriptions', (table) => { + table.dropColumn('cancellation_effective_date'); + }); +} diff --git a/packages/backend/src/db/migrations/20230415134138_drop_payment_plans.js b/packages/backend/src/db/migrations/20230415134138_drop_payment_plans.js new file mode 100644 index 0000000..51dbc3b --- /dev/null +++ b/packages/backend/src/db/migrations/20230415134138_drop_payment_plans.js @@ -0,0 +1,24 @@ +import appConfig from '../../config/app.js'; + +export async function up(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.dropTable('payment_plans'); +} + +export async function down(knex) { + if (!appConfig.isCloud) return; + + return knex.schema.createTable('payment_plans', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').notNullable(); + table.integer('task_count').notNullable(); + table.uuid('user_id').references('id').inTable('users'); + table.string('stripe_customer_id'); + table.string('stripe_subscription_id'); + table.timestamp('current_period_started_at').nullable(); + table.timestamp('current_period_ends_at').nullable(); + table.timestamp('deleted_at').nullable(); + table.timestamps(true, true); + }); +} diff --git a/packages/backend/src/db/migrations/20230609201228_add_webhook_path_in_step.js b/packages/backend/src/db/migrations/20230609201228_add_webhook_path_in_step.js new file mode 100644 index 0000000..361e711 --- /dev/null +++ b/packages/backend/src/db/migrations/20230609201228_add_webhook_path_in_step.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('steps', (table) => { + table.string('webhook_path'); + }); +} + +export async function down(knex) { + return knex.schema.table('steps', (table) => { + table.dropColumn('webhook_path'); + }); +} diff --git a/packages/backend/src/db/migrations/20230609201909_populate_data_in_webhook_path_in_step.js b/packages/backend/src/db/migrations/20230609201909_populate_data_in_webhook_path_in_step.js new file mode 100644 index 0000000..37a23c0 --- /dev/null +++ b/packages/backend/src/db/migrations/20230609201909_populate_data_in_webhook_path_in_step.js @@ -0,0 +1,23 @@ +export async function up(knex) { + return await knex('steps') + .where('type', 'trigger') + .whereIn('app_key', [ + 'gitlab', + 'typeform', + 'twilio', + 'flowers-software', + 'webhook', + ]) + .update({ + webhook_path: knex.raw('? || ??', [ + '/webhooks/flows/', + knex.ref('flow_id'), + ]), + }); +} + +export async function down(knex) { + return await knex('steps').update({ + webhook_path: null, + }); +} diff --git a/packages/backend/src/db/migrations/20230615200200_create_roles.js b/packages/backend/src/db/migrations/20230615200200_create_roles.js new file mode 100644 index 0000000..5593b6c --- /dev/null +++ b/packages/backend/src/db/migrations/20230615200200_create_roles.js @@ -0,0 +1,43 @@ +import capitalize from 'lodash/capitalize.js'; +import lowerCase from 'lodash/lowerCase.js'; + +export async function up(knex) { + await knex.schema.createTable('roles', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').notNullable(); + table.string('key').notNullable(); + table.string('description'); + + table.timestamps(true, true); + }); + + const uniqueUserRoles = await knex('users').select('role').groupBy('role'); + + let shouldCreateAdminRole = true; + for (const { role } of uniqueUserRoles) { + // skip empty roles + if (!role) continue; + + const lowerCaseRole = lowerCase(role); + + if (lowerCaseRole === 'admin') { + shouldCreateAdminRole = false; + } + + await knex('roles').insert({ + name: capitalize(role), + key: lowerCaseRole, + }); + } + + if (shouldCreateAdminRole) { + await knex('roles').insert({ + name: 'Admin', + key: 'admin', + }); + } +} + +export async function down(knex) { + return knex.schema.dropTable('roles'); +} diff --git a/packages/backend/src/db/migrations/20230615205857_create_permissions.js b/packages/backend/src/db/migrations/20230615205857_create_permissions.js new file mode 100644 index 0000000..598d16d --- /dev/null +++ b/packages/backend/src/db/migrations/20230615205857_create_permissions.js @@ -0,0 +1,66 @@ +const getPermissionForRole = (roleId, subject, actions, conditions = []) => + actions.map((action) => ({ + role_id: roleId, + subject, + action, + conditions, + })); + +export async function up(knex) { + await knex.schema.createTable('permissions', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('role_id').references('id').inTable('roles'); + table.string('action').notNullable(); + table.string('subject').notNullable(); + table.jsonb('conditions').notNullable().defaultTo([]); + + table.timestamps(true, true); + }); + + const roles = await knex('roles').select(['id', 'key']); + + for (const role of roles) { + // `admin` role should have no conditions unlike others by default + const isAdmin = role.key === 'admin'; + const roleConditions = isAdmin ? [] : ['isCreator']; + + // default permissions + await knex('permissions').insert([ + ...getPermissionForRole( + role.id, + 'Connection', + ['create', 'read', 'delete', 'update'], + roleConditions + ), + ...getPermissionForRole(role.id, 'Execution', ['read'], roleConditions), + ...getPermissionForRole( + role.id, + 'Flow', + ['create', 'delete', 'publish', 'read', 'update'], + roleConditions + ), + ]); + + // admin specific permission + if (isAdmin) { + await knex('permissions').insert([ + ...getPermissionForRole(role.id, 'User', [ + 'create', + 'read', + 'delete', + 'update', + ]), + ...getPermissionForRole(role.id, 'Role', [ + 'create', + 'read', + 'delete', + 'update', + ]), + ]); + } + } +} + +export async function down(knex) { + return knex.schema.dropTable('permissions'); +} diff --git a/packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.js b/packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.js new file mode 100644 index 0000000..f3fba1d --- /dev/null +++ b/packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.js @@ -0,0 +1,27 @@ +export async function up(knex) { + await knex.schema.table('users', async (table) => { + table.uuid('role_id').references('id').inTable('roles'); + }); + + const theRole = await knex('roles').select('id').limit(1).first(); + const roles = await knex('roles').select('id', 'key'); + + for (const role of roles) { + await knex('users') + .where({ + role: role.key, + }) + .update({ + role_id: role.id, + }); + } + + // backfill not-migratables + await knex('users').whereNull('role_id').update({ role_id: theRole.id }); +} + +export async function down(knex) { + return await knex.schema.table('users', (table) => { + table.dropColumn('role_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.js b/packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.js new file mode 100644 index 0000000..5600325 --- /dev/null +++ b/packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex.schema.table('users', async (table) => { + table.dropColumn('role'); + }); +} + +export async function down(knex) { + return await knex.schema.table('users', (table) => { + table.string('role').defaultTo('user'); + }); +} diff --git a/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.js b/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.js new file mode 100644 index 0000000..41cb01d --- /dev/null +++ b/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.js @@ -0,0 +1,22 @@ +export async function up(knex) { + return knex.schema.createTable('saml_auth_providers', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').notNullable(); + table.text('certificate').notNullable(); + table.string('signature_algorithm').notNullable(); + table.string('issuer').notNullable(); + table.text('entry_point').notNullable(); + table.text('firstname_attribute_name').notNullable(); + table.text('surname_attribute_name').notNullable(); + table.text('email_attribute_name').notNullable(); + table.text('role_attribute_name').notNullable(); + table.uuid('default_role_id').references('id').inTable('roles'); + table.boolean('active').defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('saml_auth_providers'); +} diff --git a/packages/backend/src/db/migrations/20230707094923_create_identities.js b/packages/backend/src/db/migrations/20230707094923_create_identities.js new file mode 100644 index 0000000..99be1f3 --- /dev/null +++ b/packages/backend/src/db/migrations/20230707094923_create_identities.js @@ -0,0 +1,15 @@ +export async function up(knex) { + return knex.schema.createTable('identities', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users'); + table.string('remote_id').notNullable(); + table.string('provider_id').notNullable(); + table.string('provider_type').notNullable(); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('identities'); +} diff --git a/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.js b/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.js new file mode 100644 index 0000000..7779d8e --- /dev/null +++ b/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.js @@ -0,0 +1,9 @@ +export async function up(knex) { + return await knex.schema.alterTable('users', (table) => { + table.string('password').nullable().alter(); + }); +} + +export async function down() { + // void +} diff --git a/packages/backend/src/db/migrations/20230807114158_seed_saml_permissions_to_admin.js b/packages/backend/src/db/migrations/20230807114158_seed_saml_permissions_to_admin.js new file mode 100644 index 0000000..16ff868 --- /dev/null +++ b/packages/backend/src/db/migrations/20230807114158_seed_saml_permissions_to_admin.js @@ -0,0 +1,27 @@ +const getPermissionForRole = (roleId, subject, actions) => + actions.map((action) => ({ + role_id: roleId, + subject, + action, + conditions: [], + })); + +export async function up(knex) { + const role = await knex('roles') + .first(['id', 'key']) + .where({ key: 'admin' }) + .limit(1); + + await knex('permissions').insert( + getPermissionForRole(role.id, 'SamlAuthProvider', [ + 'create', + 'read', + 'delete', + 'update', + ]) + ); +} + +export async function down(knex) { + await knex('permissions').where({ subject: 'SamlAuthProvider' }).delete(); +} diff --git a/packages/backend/src/db/migrations/20230810124730_create_config.js b/packages/backend/src/db/migrations/20230810124730_create_config.js new file mode 100644 index 0000000..9b0ef17 --- /dev/null +++ b/packages/backend/src/db/migrations/20230810124730_create_config.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.createTable('config', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').unique().notNullable(); + table.jsonb('value').notNullable().defaultTo({}); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('config'); +} diff --git a/packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.js b/packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.js new file mode 100644 index 0000000..63c7da6 --- /dev/null +++ b/packages/backend/src/db/migrations/20230810134714_seed_update_config_permissions_to_admin.js @@ -0,0 +1,22 @@ +const getPermissionForRole = (roleId, subject, actions) => + actions.map((action) => ({ + role_id: roleId, + subject, + action, + conditions: [], + })); + +export async function up(knex) { + const role = await knex('roles') + .first(['id', 'key']) + .where({ key: 'admin' }) + .limit(1); + + await knex('permissions').insert( + getPermissionForRole(role.id, 'Config', ['update']) + ); +} + +export async function down(knex) { + await knex('permissions').where({ subject: 'Config' }).delete(); +} diff --git a/packages/backend/src/db/migrations/20230811142340_create_saml_auth_providers_role_mappings.js b/packages/backend/src/db/migrations/20230811142340_create_saml_auth_providers_role_mappings.js new file mode 100644 index 0000000..0f31079 --- /dev/null +++ b/packages/backend/src/db/migrations/20230811142340_create_saml_auth_providers_role_mappings.js @@ -0,0 +1,22 @@ +export async function up(knex) { + return knex.schema.createTable( + 'saml_auth_providers_role_mappings', + (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table + .uuid('saml_auth_provider_id') + .references('id') + .inTable('saml_auth_providers'); + table.uuid('role_id').references('id').inTable('roles'); + table.string('remote_role_name').notNullable(); + + table.unique(['saml_auth_provider_id', 'remote_role_name']); + + table.timestamps(true, true); + } + ); +} + +export async function down(knex) { + return knex.schema.dropTable('saml_auth_providers_role_mappings'); +} diff --git a/packages/backend/src/db/migrations/20230812132005_create_app_configs.js b/packages/backend/src/db/migrations/20230812132005_create_app_configs.js new file mode 100644 index 0000000..028ebbe --- /dev/null +++ b/packages/backend/src/db/migrations/20230812132005_create_app_configs.js @@ -0,0 +1,15 @@ +export async function up(knex) { + return knex.schema.createTable('app_configs', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').unique().notNullable(); + table.boolean('allow_custom_connection').notNullable().defaultTo(false); + table.boolean('shared').notNullable().defaultTo(false); + table.boolean('disabled').notNullable().defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('app_configs'); +} diff --git a/packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.js b/packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.js new file mode 100644 index 0000000..4d690e8 --- /dev/null +++ b/packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.js @@ -0,0 +1,19 @@ +export async function up(knex) { + return knex.schema.createTable('app_auth_clients', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').unique().notNullable(); + table + .uuid('app_config_id') + .notNullable() + .references('id') + .inTable('app_configs'); + table.text('auth_defaults').notNullable(); + table.boolean('active').notNullable().defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('app_auth_clients'); +} diff --git a/packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.js b/packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.js new file mode 100644 index 0000000..c521092 --- /dev/null +++ b/packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.js @@ -0,0 +1,14 @@ +export async function up(knex) { + await knex.schema.table('connections', async (table) => { + table + .uuid('app_auth_client_id') + .references('id') + .inTable('app_auth_clients'); + }); +} + +export async function down(knex) { + return await knex.schema.table('connections', (table) => { + table.dropColumn('app_auth_client_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.js b/packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.js new file mode 100644 index 0000000..ae99045 --- /dev/null +++ b/packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.js @@ -0,0 +1,22 @@ +const getPermissionForRole = (roleId, subject, actions) => + actions.map((action) => ({ + role_id: roleId, + subject, + action, + conditions: [], + })); + +export async function up(knex) { + const role = await knex('roles') + .first(['id', 'key']) + .where({ key: 'admin' }) + .limit(1); + + await knex('permissions').insert( + getPermissionForRole(role.id, 'App', ['create', 'read', 'delete', 'update']) + ); +} + +export async function down(knex) { + await knex('permissions').where({ subject: 'App' }).delete(); +} diff --git a/packages/backend/src/db/migrations/20230816173027_make_role_id_not_nullable_in_users.js b/packages/backend/src/db/migrations/20230816173027_make_role_id_not_nullable_in_users.js new file mode 100644 index 0000000..e39f387 --- /dev/null +++ b/packages/backend/src/db/migrations/20230816173027_make_role_id_not_nullable_in_users.js @@ -0,0 +1,23 @@ +export async function up(knex) { + const role = await knex('roles') + .select('id') + .whereIn('key', ['user', 'admin']) + .orderBy('key', 'desc') + .limit(1) + .first(); + + if (role) { + // backfill nulls + await knex('users').whereNull('role_id').update({ role_id: role.id }); + } + + return await knex.schema.alterTable('users', (table) => { + table.uuid('role_id').notNullable().alter(); + }); +} + +export async function down(knex) { + return await knex.schema.alterTable('users', (table) => { + table.uuid('role_id').nullable().alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20230824105813_soft_delete_soft_deleted_user_associations.js b/packages/backend/src/db/migrations/20230824105813_soft_delete_soft_deleted_user_associations.js new file mode 100644 index 0000000..fdd1b6c --- /dev/null +++ b/packages/backend/src/db/migrations/20230824105813_soft_delete_soft_deleted_user_associations.js @@ -0,0 +1,33 @@ +export async function up(knex) { + const users = await knex('users').whereNotNull('deleted_at'); + const userIds = users.map((user) => user.id); + + const flows = await knex('flows').whereIn('user_id', userIds); + const flowIds = flows.map((flow) => flow.id); + const executions = await knex('executions').whereIn('flow_id', flowIds); + const executionIds = executions.map((execution) => execution.id); + + await knex('execution_steps').whereIn('execution_id', executionIds).update({ + deleted_at: knex.fn.now(), + }); + + await knex('executions').whereIn('id', executionIds).update({ + deleted_at: knex.fn.now(), + }); + + await knex('steps').whereIn('flow_id', flowIds).update({ + deleted_at: knex.fn.now(), + }); + + await knex('flows').whereIn('id', flowIds).update({ + deleted_at: knex.fn.now(), + }); + + await knex('connections').whereIn('user_id', userIds).update({ + deleted_at: knex.fn.now(), + }); +} + +export async function down() { + // void +} diff --git a/packages/backend/src/db/migrations/20230828134734_convert_permission_conditions_to_array.js b/packages/backend/src/db/migrations/20230828134734_convert_permission_conditions_to_array.js new file mode 100644 index 0000000..c6056cf --- /dev/null +++ b/packages/backend/src/db/migrations/20230828134734_convert_permission_conditions_to_array.js @@ -0,0 +1,9 @@ +export async function up(knex) { + await knex('permissions') + .where(knex.raw('conditions::text'), '=', knex.raw("'{}'::text")) + .update('conditions', JSON.stringify([])); +} + +export async function down() { + // void +} diff --git a/packages/backend/src/db/migrations/20231013094544_convert_user_emails_to_lowercase.js b/packages/backend/src/db/migrations/20231013094544_convert_user_emails_to_lowercase.js new file mode 100644 index 0000000..e179ff4 --- /dev/null +++ b/packages/backend/src/db/migrations/20231013094544_convert_user_emails_to_lowercase.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex('users') + .whereRaw('email != LOWER(email)') + .update({ + email: knex.raw('LOWER(email)'), + }); +} + +export async function down() { + // void +} diff --git a/packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.js b/packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.js new file mode 100644 index 0000000..be9c113 --- /dev/null +++ b/packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex.schema.table('executions', (table) => { + table.index('flow_id'); + }); +} + +export async function down(knex) { + await knex.schema.table('executions', (table) => { + table.dropIndex('flow_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.js b/packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.js new file mode 100644 index 0000000..43d95dd --- /dev/null +++ b/packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex.schema.table('executions', (table) => { + table.index('updated_at'); + }); +} + +export async function down(knex) { + await knex.schema.table('executions', (table) => { + table.dropIndex('updated_at'); + }); +} diff --git a/packages/backend/src/db/migrations/20240227164849_create_datastore_model.js b/packages/backend/src/db/migrations/20240227164849_create_datastore_model.js new file mode 100644 index 0000000..c303989 --- /dev/null +++ b/packages/backend/src/db/migrations/20240227164849_create_datastore_model.js @@ -0,0 +1,16 @@ +export async function up(knex) { + return knex.schema.createTable('datastore', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').notNullable(); + table.string('value'); + table.string('scope').notNullable(); + table.uuid('scope_id').notNullable(); + table.index(['key', 'scope', 'scope_id']); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('datastore'); +} diff --git a/packages/backend/src/db/migrations/20240326194638_add_app_key_to_app_auth_clients.js b/packages/backend/src/db/migrations/20240326194638_add_app_key_to_app_auth_clients.js new file mode 100644 index 0000000..4ab8210 --- /dev/null +++ b/packages/backend/src/db/migrations/20240326194638_add_app_key_to_app_auth_clients.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex.schema.table('app_auth_clients', (table) => { + table.string('app_key'); + }); +} + +export async function down(knex) { + await knex.schema.table('app_auth_clients', (table) => { + table.dropColumn('app_key'); + }); +} diff --git a/packages/backend/src/db/migrations/20240326195028_migrate_app_config_id_to_app_key.js b/packages/backend/src/db/migrations/20240326195028_migrate_app_config_id_to_app_key.js new file mode 100644 index 0000000..05842ad --- /dev/null +++ b/packages/backend/src/db/migrations/20240326195028_migrate_app_config_id_to_app_key.js @@ -0,0 +1,17 @@ +export async function up(knex) { + const appAuthClients = await knex('app_auth_clients').select('*'); + + for (const appAuthClient of appAuthClients) { + const appConfig = await knex('app_configs') + .where('id', appAuthClient.app_config_id) + .first(); + + await knex('app_auth_clients') + .where('id', appAuthClient.id) + .update({ app_key: appConfig.key }); + } +} + +export async function down() { + // void +} diff --git a/packages/backend/src/db/migrations/20240326195748_remove_app_config_id_from_app_auth_clients.js b/packages/backend/src/db/migrations/20240326195748_remove_app_config_id_from_app_auth_clients.js new file mode 100644 index 0000000..12b7c7d --- /dev/null +++ b/packages/backend/src/db/migrations/20240326195748_remove_app_config_id_from_app_auth_clients.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex.schema.table('app_auth_clients', (table) => { + table.dropColumn('app_config_id'); + }); +} + +export async function down(knex) { + await knex.schema.table('app_auth_clients', (table) => { + table.uuid('app_config_id').references('id').inTable('app_configs'); + }); +} diff --git a/packages/backend/src/db/migrations/20240326202110_add_not_nullable_to_app_key_of_app_auth_clients.js b/packages/backend/src/db/migrations/20240326202110_add_not_nullable_to_app_key_of_app_auth_clients.js new file mode 100644 index 0000000..9c6145b --- /dev/null +++ b/packages/backend/src/db/migrations/20240326202110_add_not_nullable_to_app_key_of_app_auth_clients.js @@ -0,0 +1,11 @@ +export async function up(knex) { + await knex.schema.table('app_auth_clients', (table) => { + table.string('app_key').notNullable().alter(); + }); +} + +export async function down(knex) { + await knex.schema.table('app_auth_clients', (table) => { + table.string('app_key').nullable().alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20240422130323_create_access_tokens.js b/packages/backend/src/db/migrations/20240422130323_create_access_tokens.js new file mode 100644 index 0000000..d71af5b --- /dev/null +++ b/packages/backend/src/db/migrations/20240422130323_create_access_tokens.js @@ -0,0 +1,15 @@ +export async function up(knex) { + return knex.schema.createTable('access_tokens', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('token').notNullable(); + table.integer('expires_in').notNullable(); + table.timestamp('revoked_at').nullable(); + table.uuid('user_id').references('id').inTable('users'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('access_tokens'); +} diff --git a/packages/backend/src/db/migrations/20240424100113_add_indexes_to_access_tokens.js b/packages/backend/src/db/migrations/20240424100113_add_indexes_to_access_tokens.js new file mode 100644 index 0000000..0cb5d96 --- /dev/null +++ b/packages/backend/src/db/migrations/20240424100113_add_indexes_to_access_tokens.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.table('access_tokens', (table) => { + table.index('token'); + table.index('user_id'); + }); +} + +export async function down(knex) { + return knex.schema.table('access_tokens', (table) => { + table.dropIndex('token'); + table.dropIndex('user_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js b/packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js new file mode 100644 index 0000000..42423bd --- /dev/null +++ b/packages/backend/src/db/migrations/20240430132947_add_saml_session_id_in_access_tokens.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('access_tokens', (table) => { + table.string('saml_session_id').nullable(); + }); +} + +export async function down(knex) { + return knex.schema.table('access_tokens', (table) => { + table.dropColumn('saml_session_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js b/packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js new file mode 100644 index 0000000..0ffbfd6 --- /dev/null +++ b/packages/backend/src/db/migrations/20240507135944_update_installation_completed_for_config.js @@ -0,0 +1,17 @@ +export async function up(knex) { + const users = await knex('users').limit(1); + + // no user implies installation is not completed yet. + if (users.length === 0) return; + + await knex('config').insert({ + key: 'installation.completed', + value: { + data: true + } + }); +}; + +export async function down(knex) { + await knex('config').where({ key: 'installation.completed' }).delete(); +}; diff --git a/packages/backend/src/db/migrations/20240509202750_make_value_column_text_in_datastore.js b/packages/backend/src/db/migrations/20240509202750_make_value_column_text_in_datastore.js new file mode 100644 index 0000000..67c5a67 --- /dev/null +++ b/packages/backend/src/db/migrations/20240509202750_make_value_column_text_in_datastore.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.alterTable('datastore', (table) => { + table.text('value').alter(); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('datastore', (table) => { + table.string('value').alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20240708140250_add_status_to_users.js b/packages/backend/src/db/migrations/20240708140250_add_status_to_users.js new file mode 100644 index 0000000..c47daf2 --- /dev/null +++ b/packages/backend/src/db/migrations/20240708140250_add_status_to_users.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('users', (table) => { + table.string('status').defaultTo('active'); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('status'); + }); +} diff --git a/packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js b/packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js new file mode 100644 index 0000000..a60168e --- /dev/null +++ b/packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.table('users', (table) => { + table.string('invitation_token'); + table.timestamp('invitation_token_sent_at'); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('invitation_token'); + table.dropColumn('invitation_token_sent_at'); + }); +} diff --git a/packages/backend/src/db/migrations/20240903110620_make_role_name_unique.js b/packages/backend/src/db/migrations/20240903110620_make_role_name_unique.js new file mode 100644 index 0000000..bdccb76 --- /dev/null +++ b/packages/backend/src/db/migrations/20240903110620_make_role_name_unique.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return await knex.schema.alterTable('roles', (table) => { + table.unique('name'); + }); +} + +export async function down(knex) { + return await knex.schema.alterTable('roles', function (table) { + table.dropUnique('name'); + }); +} diff --git a/packages/backend/src/db/migrations/20240904091615_remove_key_column_in_roles.js b/packages/backend/src/db/migrations/20240904091615_remove_key_column_in_roles.js new file mode 100644 index 0000000..d8c8713 --- /dev/null +++ b/packages/backend/src/db/migrations/20240904091615_remove_key_column_in_roles.js @@ -0,0 +1,19 @@ +export async function up(knex) { + return await knex.schema.alterTable('roles', (table) => { + table.dropColumn('key'); + }); +} + +export async function down(knex) { + await knex.schema.alterTable('roles', (table) => { + table.string('key'); + }); + + await knex('roles').update({ + key: knex.raw('LOWER(??)', ['name']), + }); + + return await knex.schema.alterTable('roles', (table) => { + table.string('key').notNullable().alter(); + }); +} diff --git a/packages/backend/src/db/migrations/20240919100138_make_config_single_record.js b/packages/backend/src/db/migrations/20240919100138_make_config_single_record.js new file mode 100644 index 0000000..552d949 --- /dev/null +++ b/packages/backend/src/db/migrations/20240919100138_make_config_single_record.js @@ -0,0 +1,105 @@ +export async function up(knex) { + await knex.schema.alterTable('config', (table) => { + table.dropUnique('key'); + + table.string('key').nullable().alter(); + table.boolean('installation_completed').defaultTo(false); + table.text('logo_svg_data'); + table.text('palette_primary_dark'); + table.text('palette_primary_light'); + table.text('palette_primary_main'); + table.string('title'); + }); + + const config = await knex('config').select('key', 'value'); + + const newConfigData = { + logo_svg_data: getValueForKey(config, 'logo.svgData'), + palette_primary_dark: getValueForKey(config, 'palette.primary.dark'), + palette_primary_light: getValueForKey(config, 'palette.primary.light'), + palette_primary_main: getValueForKey(config, 'palette.primary.main'), + title: getValueForKey(config, 'title'), + installation_completed: getValueForKey(config, 'installation.completed'), + }; + + const [configEntry] = await knex('config') + .insert(newConfigData) + .select('id') + .returning('id'); + + await knex('config').where('id', '!=', configEntry.id).delete(); + + await knex.schema.alterTable('config', (table) => { + table.dropColumn('key'); + table.dropColumn('value'); + }); +} + +export async function down(knex) { + await knex.schema.alterTable('config', (table) => { + table.string('key'); + table.jsonb('value').notNullable().defaultTo({}); + }); + + const configRow = await knex('config').first(); + + const config = [ + { + key: 'logo.svgData', + value: { + data: configRow.logo_svg_data, + }, + }, + { + key: 'palette.primary.dark', + value: { + data: configRow.palette_primary_dark, + }, + }, + { + key: 'palette.primary.light', + value: { + data: configRow.palette_primary_light, + }, + }, + { + key: 'palette.primary.main', + value: { + data: configRow.palette_primary_main, + }, + }, + { + key: 'title', + value: { + data: configRow.title, + }, + }, + { + key: 'installation.completed', + value: { + data: configRow.installation_completed, + }, + }, + ]; + + await knex('config').insert(config).returning('id'); + + await knex('config').where('id', '=', configRow.id).delete(); + + await knex.schema.alterTable('config', (table) => { + table.dropColumn('installation_completed'); + table.dropColumn('logo_svg_data'); + table.dropColumn('palette_primary_dark'); + table.dropColumn('palette_primary_light'); + table.dropColumn('palette_primary_main'); + table.dropColumn('title'); + + table.string('key').unique().notNullable().alter(); + }); +} + +function getValueForKey(rows, key) { + const row = rows.find((row) => row.key === key); + + return row?.value?.data || null; +} diff --git a/packages/backend/src/db/migrations/20241002121145_add_connection_allowed_to_app_configs.js b/packages/backend/src/db/migrations/20241002121145_add_connection_allowed_to_app_configs.js new file mode 100644 index 0000000..3b12b08 --- /dev/null +++ b/packages/backend/src/db/migrations/20241002121145_add_connection_allowed_to_app_configs.js @@ -0,0 +1,37 @@ +export async function up(knex) { + await knex.schema.alterTable('app_configs', (table) => { + table.boolean('connection_allowed').defaultTo(false); + }); + + const appConfigs = await knex('app_configs').select('*'); + + for (const appConfig of appConfigs) { + const appAuthClients = await knex('app_auth_clients').where( + 'app_key', + appConfig.key + ); + + const hasSomeActiveAppAuthClients = !!appAuthClients?.some( + (appAuthClient) => appAuthClient.active + ); + const shared = appConfig.shared; + const active = appConfig.disabled === false; + + const connectionAllowedConditions = [ + hasSomeActiveAppAuthClients, + shared, + active, + ]; + const connectionAllowed = connectionAllowedConditions.every(Boolean); + + await knex('app_configs') + .where('id', appConfig.id) + .update({ connection_allowed: connectionAllowed }); + } +} + +export async function down(knex) { + await knex.schema.alterTable('app_configs', (table) => { + table.dropColumn('connection_allowed'); + }); +} diff --git a/packages/backend/src/db/migrations/20241009094438_rename_allow_custom_connection_as_custom_connection_allowed_in_app_configs.js b/packages/backend/src/db/migrations/20241009094438_rename_allow_custom_connection_as_custom_connection_allowed_in_app_configs.js new file mode 100644 index 0000000..b8fab01 --- /dev/null +++ b/packages/backend/src/db/migrations/20241009094438_rename_allow_custom_connection_as_custom_connection_allowed_in_app_configs.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.alterTable('app_configs', (table) => { + table.renameColumn('allow_custom_connection', 'custom_connection_allowed'); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('app_configs', (table) => { + table.renameColumn('custom_connection_allowed', 'allow_custom_connection'); + }); +} diff --git a/packages/backend/src/db/migrations/20241024130418_make_key_primary_for_app_configs.js b/packages/backend/src/db/migrations/20241024130418_make_key_primary_for_app_configs.js new file mode 100644 index 0000000..cae7f31 --- /dev/null +++ b/packages/backend/src/db/migrations/20241024130418_make_key_primary_for_app_configs.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.alterTable('app_configs', function (table) { + table.dropPrimary(); + table.primary('key'); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('app_configs', function (table) { + table.dropPrimary(); + table.primary('id'); + }); +} diff --git a/packages/backend/src/db/migrations/20241024131158_remove_id_column_from_app_configs.js b/packages/backend/src/db/migrations/20241024131158_remove_id_column_from_app_configs.js new file mode 100644 index 0000000..9059828 --- /dev/null +++ b/packages/backend/src/db/migrations/20241024131158_remove_id_column_from_app_configs.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.alterTable('app_configs', function (table) { + table.dropColumn('id'); + }); +} + +export async function down(knex) { + return knex.schema.alterTable('app_configs', function (table) { + table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')); + }); +} diff --git a/packages/backend/src/db/migrations/20241125141647_rename_saml_auth_providers_role_mappings_as_role_mappings.js b/packages/backend/src/db/migrations/20241125141647_rename_saml_auth_providers_role_mappings_as_role_mappings.js new file mode 100644 index 0000000..b8abfe8 --- /dev/null +++ b/packages/backend/src/db/migrations/20241125141647_rename_saml_auth_providers_role_mappings_as_role_mappings.js @@ -0,0 +1,52 @@ +export async function up(knex) { + await knex.schema.createTable('role_mappings', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table + .uuid('saml_auth_provider_id') + .references('id') + .inTable('saml_auth_providers'); + table.uuid('role_id').references('id').inTable('roles'); + table.string('remote_role_name').notNullable(); + + table.unique(['saml_auth_provider_id', 'remote_role_name']); + + table.timestamps(true, true); + }); + + const existingRoleMappings = await knex('saml_auth_providers_role_mappings'); + + if (existingRoleMappings.length) { + await knex('role_mappings').insert(existingRoleMappings); + } + + return await knex.schema.dropTable('saml_auth_providers_role_mappings'); +} + +export async function down(knex) { + await knex.schema.createTable( + 'saml_auth_providers_role_mappings', + (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table + .uuid('saml_auth_provider_id') + .references('id') + .inTable('saml_auth_providers'); + table.uuid('role_id').references('id').inTable('roles'); + table.string('remote_role_name').notNullable(); + + table.unique(['saml_auth_provider_id', 'remote_role_name']); + + table.timestamps(true, true); + } + ); + + const existingRoleMappings = await knex('role_mappings'); + + if (existingRoleMappings.length) { + await knex('saml_auth_providers_role_mappings').insert( + existingRoleMappings + ); + } + + return await knex.schema.dropTable('role_mappings'); +} diff --git a/packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js b/packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js new file mode 100644 index 0000000..1865f05 --- /dev/null +++ b/packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.boolean('use_only_predefined_auth_clients').defaultTo(false); + }); +} + +export async function down(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.dropColumn('use_only_predefined_auth_clients'); + }); +} diff --git a/packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js b/packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js new file mode 100644 index 0000000..a99bc9e --- /dev/null +++ b/packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js @@ -0,0 +1,15 @@ +export async function up(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.dropColumn('shared'); + table.dropColumn('connection_allowed'); + table.dropColumn('custom_connection_allowed'); + }); +} + +export async function down(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.boolean('shared').defaultTo(false); + table.boolean('connection_allowed').defaultTo(false); + table.boolean('custom_connection_allowed').defaultTo(false); + }); +} diff --git a/packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js b/packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js new file mode 100644 index 0000000..a26ad1f --- /dev/null +++ b/packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js @@ -0,0 +1,31 @@ +export async function up(knex) { + await knex.schema.renameTable('app_auth_clients', 'oauth_clients'); + + await knex.schema.raw( + 'ALTER INDEX app_auth_clients_pkey RENAME TO oauth_clients_pkey' + ); + + await knex.schema.raw( + 'ALTER INDEX app_auth_clients_name_unique RENAME TO oauth_clients_name_unique' + ); + + return await knex.schema.alterTable('connections', (table) => { + table.renameColumn('app_auth_client_id', 'oauth_client_id'); + }); +} + +export async function down(knex) { + await knex.schema.renameTable('oauth_clients', 'app_auth_clients'); + + await knex.schema.raw( + 'ALTER INDEX oauth_clients_pkey RENAME TO app_auth_clients_pkey' + ); + + await knex.schema.raw( + 'ALTER INDEX oauth_clients_name_unique RENAME TO app_auth_clients_name_unique' + ); + + return await knex.schema.alterTable('connections', (table) => { + table.renameColumn('oauth_client_id', 'app_auth_client_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js b/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js new file mode 100644 index 0000000..bde4d6c --- /dev/null +++ b/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js @@ -0,0 +1,26 @@ +import toLower from 'lodash/toLower.js'; +import startCase from 'lodash/startCase.js'; +import upperFirst from 'lodash/upperFirst.js'; + +export async function up(knex) { + await knex.schema.table('steps', function (table) { + table.string('name'); + }); + + const rows = await knex('steps').select('id', 'key'); + + const updates = rows.map((row) => { + if (!row.key) return; + + const humanizedKey = upperFirst(toLower(startCase(row.key))); + return knex('steps').where({ id: row.id }).update({ name: humanizedKey }); + }); + + return await Promise.all(updates); +} + +export async function down(knex) { + return knex.schema.table('steps', function (table) { + table.dropColumn('name'); + }); +} diff --git a/packages/backend/src/db/migrations/20250124105728_create_folders.js b/packages/backend/src/db/migrations/20250124105728_create_folders.js new file mode 100644 index 0000000..60a9f78 --- /dev/null +++ b/packages/backend/src/db/migrations/20250124105728_create_folders.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.createTable('folders', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name'); + table.uuid('user_id').references('id').inTable('users'); + + table.timestamps(true, true); + }); +} + +export async function down(knex) { + return knex.schema.dropTable('folders'); +} diff --git a/packages/backend/src/db/migrations/20250131171406_add_folder_id_to_flows.js b/packages/backend/src/db/migrations/20250131171406_add_folder_id_to_flows.js new file mode 100644 index 0000000..46b3de9 --- /dev/null +++ b/packages/backend/src/db/migrations/20250131171406_add_folder_id_to_flows.js @@ -0,0 +1,12 @@ +export async function up(knex) { + await knex.schema.table('flows', (table) => { + table.uuid('folder_id').references('id').inTable('folders').index(); + }); +} + +export async function down(knex) { + await knex.schema.table('flows', (table) => { + table.dropIndex('folder_id'); + table.dropColumn('folder_id'); + }); +} diff --git a/packages/backend/src/errors/already-processed.js b/packages/backend/src/errors/already-processed.js new file mode 100644 index 0000000..d38b0ea --- /dev/null +++ b/packages/backend/src/errors/already-processed.js @@ -0,0 +1,3 @@ +import BaseError from './base.js'; + +export default class AlreadyProcessedError extends BaseError {} diff --git a/packages/backend/src/errors/base.js b/packages/backend/src/errors/base.js new file mode 100644 index 0000000..33dfb57 --- /dev/null +++ b/packages/backend/src/errors/base.js @@ -0,0 +1,33 @@ +export default class BaseError extends Error { + details = {}; + + constructor(error) { + let computedError; + + try { + computedError = JSON.parse(error); + } catch { + computedError = + typeof error === 'string' || Array.isArray(error) ? { error } : error; + } + + let computedMessage; + + try { + // challenge to input to see if it is stringified JSON + JSON.parse(error); + computedMessage = error; + } catch { + if (typeof error === 'string') { + computedMessage = error; + } else { + computedMessage = JSON.stringify(error, null, 2); + } + } + + super(computedMessage); + + this.details = computedError; + this.name = this.constructor.name; + } +} diff --git a/packages/backend/src/errors/early-exit.js b/packages/backend/src/errors/early-exit.js new file mode 100644 index 0000000..5e8a245 --- /dev/null +++ b/packages/backend/src/errors/early-exit.js @@ -0,0 +1,3 @@ +import BaseError from './base.js'; + +export default class EarlyExitError extends BaseError {} diff --git a/packages/backend/src/errors/generate-auth-url.js b/packages/backend/src/errors/generate-auth-url.js new file mode 100644 index 0000000..734a300 --- /dev/null +++ b/packages/backend/src/errors/generate-auth-url.js @@ -0,0 +1,10 @@ +import BaseError from './base'; + +export default class GenerateAuthUrlError extends BaseError { + constructor(error) { + const computedError = error.response?.data || error.message; + super(computedError); + + this.message = `Error occured while creating authorization URL!`; + } +} diff --git a/packages/backend/src/errors/http.js b/packages/backend/src/errors/http.js new file mode 100644 index 0000000..1d0af01 --- /dev/null +++ b/packages/backend/src/errors/http.js @@ -0,0 +1,10 @@ +import BaseError from './base.js'; + +export default class HttpError extends BaseError { + constructor(error) { + const computedError = error.response?.data || error.message; + super(computedError); + + this.response = error.response; + } +} diff --git a/packages/backend/src/errors/not-authorized.js b/packages/backend/src/errors/not-authorized.js new file mode 100644 index 0000000..ff0621b --- /dev/null +++ b/packages/backend/src/errors/not-authorized.js @@ -0,0 +1,3 @@ +import BaseError from './base.js'; + +export default class NotAuthorized extends BaseError {} diff --git a/packages/backend/src/errors/quote-exceeded.js b/packages/backend/src/errors/quote-exceeded.js new file mode 100644 index 0000000..7ca48d3 --- /dev/null +++ b/packages/backend/src/errors/quote-exceeded.js @@ -0,0 +1,9 @@ +import BaseError from './base.js'; + +export default class QuotaExceededError extends BaseError { + constructor(error = 'The allowed task quota has been exhausted!') { + super(error); + + this.statusCode = 422; + } +} diff --git a/packages/backend/src/helpers/add-authentication-steps.js b/packages/backend/src/helpers/add-authentication-steps.js new file mode 100644 index 0000000..ee1bc85 --- /dev/null +++ b/packages/backend/src/helpers/add-authentication-steps.js @@ -0,0 +1,128 @@ +function addAuthenticationSteps(app) { + if (app.auth.generateAuthUrl) { + app.auth.authenticationSteps = authenticationStepsWithAuthUrl; + app.auth.sharedAuthenticationSteps = sharedAuthenticationStepsWithAuthUrl; + } else { + app.auth.authenticationSteps = authenticationStepsWithoutAuthUrl; + } + + return app; +} + +const authenticationStepsWithoutAuthUrl = [ + { + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'formattedData', + value: '{fields.all}', + }, + ], + }, + { + type: 'mutation', + name: 'verifyConnection', + arguments: [], + }, +]; + +const authenticationStepsWithAuthUrl = [ + { + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'formattedData', + value: '{fields.all}', + }, + ], + }, + { + type: 'mutation', + name: 'generateAuthUrl', + arguments: [], + }, + { + type: 'openWithPopup', + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{generateAuthUrl.url}', + }, + ], + }, + { + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'formattedData', + value: '{openAuthPopup.all}', + }, + ], + }, + { + type: 'mutation', + name: 'verifyConnection', + arguments: [], + }, +]; + +const sharedAuthenticationStepsWithAuthUrl = [ + { + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'oauthClientId', + value: '{oauthClientId}', + }, + ], + }, + { + type: 'mutation', + name: 'generateAuthUrl', + arguments: [], + }, + { + type: 'openWithPopup', + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{generateAuthUrl.url}', + }, + ], + }, + { + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'formattedData', + value: '{openAuthPopup.all}', + }, + ], + }, + { + type: 'mutation', + name: 'verifyConnection', + arguments: [], + }, +]; + +export default addAuthenticationSteps; diff --git a/packages/backend/src/helpers/add-reconnection-steps.js b/packages/backend/src/helpers/add-reconnection-steps.js new file mode 100644 index 0000000..728498e --- /dev/null +++ b/packages/backend/src/helpers/add-reconnection-steps.js @@ -0,0 +1,56 @@ +import cloneDeep from 'lodash/cloneDeep.js'; + +const resetConnectionStep = { + type: 'mutation', + name: 'resetConnection', + arguments: [], +}; + +function removeAppKeyArgument(args) { + return args.filter((argument) => argument.name !== 'key'); +} + +function replaceCreateConnectionsWithUpdate(steps) { + const updatedSteps = cloneDeep(steps); + return updatedSteps.map((step) => { + const updatedStep = { ...step }; + + if (step.name === 'createConnection') { + updatedStep.name = 'updateConnection'; + updatedStep.arguments = removeAppKeyArgument(updatedStep.arguments); + + return updatedStep; + } + + return step; + }); +} + +function addReconnectionSteps(app) { + const hasReconnectionSteps = app.auth.reconnectionSteps; + + if (hasReconnectionSteps) return app; + + if (app.auth.authenticationSteps) { + const updatedSteps = replaceCreateConnectionsWithUpdate( + app.auth.authenticationSteps + ); + + app.auth.reconnectionSteps = [resetConnectionStep, ...updatedSteps]; + } + + if (app.auth.sharedAuthenticationSteps) { + const updatedStepsWithEmbeddedDefaults = replaceCreateConnectionsWithUpdate( + app.auth.sharedAuthenticationSteps + ); + + app.auth.sharedReconnectionSteps = [ + resetConnectionStep, + ...updatedStepsWithEmbeddedDefaults, + ]; + } + + return app; +} + +export default addReconnectionSteps; diff --git a/packages/backend/src/helpers/allow-installation.js b/packages/backend/src/helpers/allow-installation.js new file mode 100644 index 0000000..33826a4 --- /dev/null +++ b/packages/backend/src/helpers/allow-installation.js @@ -0,0 +1,16 @@ +import Config from '../models/config.js'; +import User from '../models/user.js'; + +export async function allowInstallation(request, response, next) { + if (await Config.isInstallationCompleted()) { + return response.status(403).end(); + } + + const hasAnyUsers = await User.query().resultSize() > 0; + + if (hasAnyUsers) { + return response.status(403).end(); + } + + next(); +}; diff --git a/packages/backend/src/helpers/app-assets-handler.js b/packages/backend/src/helpers/app-assets-handler.js new file mode 100644 index 0000000..e76287b --- /dev/null +++ b/packages/backend/src/helpers/app-assets-handler.js @@ -0,0 +1,24 @@ +import express from 'express'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const appAssetsHandler = async (app) => { + app.use('/apps/:appKey/assets/favicon.svg', (req, res, next) => { + const { appKey } = req.params; + const svgPath = path.resolve(`${__dirname}/../apps/${appKey}/assets/favicon.svg`); + const staticFileHandlerOptions = { + /** + * Disabling fallthrough is important to respond with HTTP 404. + * Otherwise, web app might be served. + */ + fallthrough: false, + }; + const staticFileHandler = express.static(svgPath, staticFileHandlerOptions); + + return staticFileHandler(req, res, next); + }); +}; + +export default appAssetsHandler; diff --git a/packages/backend/src/helpers/app-info-converter.js b/packages/backend/src/helpers/app-info-converter.js new file mode 100644 index 0000000..98374d8 --- /dev/null +++ b/packages/backend/src/helpers/app-info-converter.js @@ -0,0 +1,30 @@ +import appConfig from '../config/app.js'; + +const appInfoConverter = (rawAppData) => { + rawAppData.iconUrl = rawAppData.iconUrl.replace( + '{BASE_URL}', + appConfig.baseUrl + ); + + rawAppData.authDocUrl = rawAppData.authDocUrl.replace( + '{DOCS_URL}', + appConfig.docsUrl + ); + + if (rawAppData.auth?.fields) { + rawAppData.auth.fields = rawAppData.auth.fields.map((field) => { + if (field.type === 'string' && typeof field.value === 'string') { + return { + ...field, + value: field.value.replace('{WEB_APP_URL}', appConfig.webAppUrl), + }; + } + + return field; + }); + } + + return rawAppData; +}; + +export default appInfoConverter; diff --git a/packages/backend/src/helpers/authentication.js b/packages/backend/src/helpers/authentication.js new file mode 100644 index 0000000..cfbb20d --- /dev/null +++ b/packages/backend/src/helpers/authentication.js @@ -0,0 +1,48 @@ +import User from '../models/user.js'; +import AccessToken from '../models/access-token.js'; + +export const isAuthenticated = async (req) => { + const token = req.headers['authorization']; + + if (token == null) return false; + + try { + const accessToken = await AccessToken.query().findOne({ + token, + revoked_at: null, + }); + + const expirationTime = + new Date(accessToken.createdAt).getTime() + accessToken.expiresIn * 1000; + + if (Date.now() > expirationTime) { + return false; + } + + const user = await accessToken.$relatedQuery('user'); + + req.currentUser = await User.query() + .findById(user.id) + .leftJoinRelated({ + role: true, + permissions: true, + }) + .withGraphFetched({ + role: true, + permissions: true, + }) + .throwIfNotFound(); + + return true; + } catch (error) { + return false; + } +}; + +export const authenticateUser = async (request, response, next) => { + if (await isAuthenticated(request)) { + next(); + } else { + return response.status(401).end(); + } +}; diff --git a/packages/backend/src/helpers/authentication.test.js b/packages/backend/src/helpers/authentication.test.js new file mode 100644 index 0000000..1b5f162 --- /dev/null +++ b/packages/backend/src/helpers/authentication.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated } from './authentication.js'; +import { createUser } from '../../test/factories/user.js'; +import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js'; + +describe('isAuthenticated', () => { + it('should return false if no token is provided', async () => { + const req = { headers: {} }; + expect(await isAuthenticated(req)).toBe(false); + }); + + it('should return false if token is invalid', async () => { + const req = { headers: { authorization: 'invalidToken' } }; + expect(await isAuthenticated(req)).toBe(false); + }); + + it('should return true if token is valid and there is a user', async () => { + const user = await createUser(); + const token = await createAuthTokenByUserId(user.id); + + const req = { headers: { authorization: token } }; + expect(await isAuthenticated(req)).toBe(true); + }); + + it('should return false if token is valid and but there is no user', async () => { + const user = await createUser(); + const token = await createAuthTokenByUserId(user.id); + await user.$query().delete(); + + const req = { headers: { authorization: token } }; + expect(await isAuthenticated(req)).toBe(false); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js new file mode 100644 index 0000000..38ec539 --- /dev/null +++ b/packages/backend/src/helpers/authorization.js @@ -0,0 +1,175 @@ +import NotAuthorizedError from '../errors/not-authorized.js'; + +const authorizationList = { + 'GET /api/v1/users/:userId': { + action: 'read', + subject: 'User', + }, + 'GET /api/v1/users/': { + action: 'read', + subject: 'User', + }, + 'GET /api/v1/users/:userId/apps': { + action: 'read', + subject: 'Connection', + }, + 'GET /api/v1/flows/:flowId': { + action: 'read', + subject: 'Flow', + }, + 'GET /api/v1/flows/': { + action: 'read', + subject: 'Flow', + }, + 'POST /api/v1/flows/': { + action: 'create', + subject: 'Flow', + }, + 'PATCH /api/v1/flows/:flowId': { + action: 'update', + subject: 'Flow', + }, + 'DELETE /api/v1/flows/:flowId': { + action: 'delete', + subject: 'Flow', + }, + 'GET /api/v1/steps/:stepId/connection': { + action: 'read', + subject: 'Flow', + }, + 'PATCH /api/v1/steps/:stepId': { + action: 'update', + subject: 'Flow', + }, + 'POST /api/v1/steps/:stepId/test': { + action: 'update', + subject: 'Flow', + }, + 'GET /api/v1/steps/:stepId/previous-steps': { + action: 'update', + subject: 'Flow', + }, + 'POST /api/v1/steps/:stepId/dynamic-fields': { + action: 'update', + subject: 'Flow', + }, + 'POST /api/v1/steps/:stepId/dynamic-data': { + action: 'update', + subject: 'Flow', + }, + 'GET /api/v1/connections/:connectionId/flows': { + action: 'read', + subject: 'Flow', + }, + 'POST /api/v1/connections/:connectionId/test': { + action: 'update', + subject: 'Connection', + }, + 'POST /api/v1/connections/:connectionId/verify': { + action: 'create', + subject: 'Connection', + }, + 'GET /api/v1/apps/:appKey/flows': { + action: 'read', + subject: 'Flow', + }, + 'GET /api/v1/apps/:appKey/connections': { + action: 'read', + subject: 'Connection', + }, + 'GET /api/v1/executions/:executionId': { + action: 'read', + subject: 'Execution', + }, + 'GET /api/v1/executions/': { + action: 'read', + subject: 'Execution', + }, + 'GET /api/v1/executions/:executionId/execution-steps': { + action: 'read', + subject: 'Execution', + }, + 'DELETE /api/v1/steps/:stepId': { + action: 'update', + subject: 'Flow', + }, + 'PATCH /api/v1/connections/:connectionId': { + action: 'update', + subject: 'Connection', + }, + 'DELETE /api/v1/connections/:connectionId': { + action: 'delete', + subject: 'Connection', + }, + 'POST /api/v1/connections/:connectionId/reset': { + action: 'create', + subject: 'Connection', + }, + 'PATCH /api/v1/flows/:flowId/status': { + action: 'publish', + subject: 'Flow', + }, + 'POST /api/v1/flows/:flowId/duplicate': { + action: 'create', + subject: 'Flow', + }, + 'POST /api/v1/flows/:flowId/export': { + action: 'update', + subject: 'Flow', + }, + 'POST /api/v1/flows/import': { + action: 'create', + subject: 'Flow', + }, + 'POST /api/v1/flows/:flowId/steps': { + action: 'update', + subject: 'Flow', + }, + 'POST /api/v1/apps/:appKey/connections': { + action: 'create', + subject: 'Connection', + }, + 'POST /api/v1/connections/:connectionId/auth-url': { + action: 'create', + subject: 'Connection', + }, + 'POST /api/v1/folders/': { + action: 'create', + subject: 'Flow', + }, + 'PATCH /api/v1/folders/:folderId': { + action: 'create', + subject: 'Flow', + }, + 'DELETE /api/v1/folders/:folderId': { + action: 'create', + subject: 'Flow', + }, + 'GET /api/v1/folders/': { + action: 'read', + subject: 'Flow', + }, + 'PATCH /api/v1/flows/:flowId/folder': { + action: 'update', + subject: 'Flow', + }, +}; + +export const authorizeUser = async (request, response, next) => { + const currentRoute = + request.method + ' ' + request.baseUrl + request.route.path; + const currentRouteRule = authorizationList[currentRoute]; + + request.currentUser.can(currentRouteRule.action, currentRouteRule.subject); + next(); +}; + +export const authorizeAdmin = async (request, response, next) => { + const role = await request.currentUser.$relatedQuery('role'); + + if (role?.isAdmin) { + next(); + } else { + throw new NotAuthorizedError(); + } +}; diff --git a/packages/backend/src/helpers/axios-with-proxy.js b/packages/backend/src/helpers/axios-with-proxy.js new file mode 100644 index 0000000..d5dc1ee --- /dev/null +++ b/packages/backend/src/helpers/axios-with-proxy.js @@ -0,0 +1,102 @@ +import axios from 'axios'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import appConfig from '../config/app.js'; + +export function createInstance(customConfig = {}, { requestInterceptor, responseErrorInterceptor } = {}) { + const config = { + ...axios.defaults, + ...customConfig + }; + const httpProxyUrl = appConfig.httpProxy; + const httpsProxyUrl = appConfig.httpsProxy; + const supportsProxy = httpProxyUrl || httpsProxyUrl; + const noProxyEnv = appConfig.noProxy; + const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : []; + + if (supportsProxy) { + if (httpProxyUrl) { + config.httpAgent = new HttpProxyAgent(httpProxyUrl); + } + + if (httpsProxyUrl) { + config.httpsAgent = new HttpsProxyAgent(httpsProxyUrl); + } + + config.proxy = false; + } + + const instance = axios.create(config); + + function shouldSkipProxy(hostname) { + return noProxyHosts.some(noProxyHost => { + return hostname.endsWith(noProxyHost) || hostname === noProxyHost; + }); + }; + + /** + * The interceptors are executed in the reverse order they are added. + */ + instance.interceptors.request.use( + function skipProxyIfInNoProxy(requestConfig) { + const hostname = new URL(requestConfig.baseURL).hostname; + + if (supportsProxy && shouldSkipProxy(hostname)) { + requestConfig.httpAgent = undefined; + requestConfig.httpsAgent = undefined; + } + + return requestConfig; + }, + (error) => Promise.reject(error) + ); + + // not always we have custom request interceptors + if (requestInterceptor) { + instance.interceptors.request.use( + async function customInterceptor(requestConfig) { + let newRequestConfig = requestConfig; + + for (const interceptor of requestInterceptor) { + newRequestConfig = await interceptor(newRequestConfig); + } + + return newRequestConfig; + } + ); + } + + instance.interceptors.request.use( + function removeBaseUrlForAbsoluteUrls(requestConfig) { + /** + * If the URL is an absolute URL, we remove its origin out of the URL + * and set it as baseURL. This lets us streamlines the requests made by Automatisch + * and requests made by app integrations. + */ + try { + const url = new URL(requestConfig.url); + requestConfig.baseURL = url.origin; + requestConfig.url = url.pathname + url.search; + + return requestConfig; + } catch (err) { + return requestConfig; + } + }, + (error) => Promise.reject(error) + ); + + // not always we have custom response error interceptor + if (responseErrorInterceptor) { + instance.interceptors.response.use( + (response) => response, + responseErrorInterceptor + ); + } + + return instance; +} + +const defaultInstance = createInstance(); + +export default defaultInstance; diff --git a/packages/backend/src/helpers/axios-with-proxy.test.js b/packages/backend/src/helpers/axios-with-proxy.test.js new file mode 100644 index 0000000..9213dbd --- /dev/null +++ b/packages/backend/src/helpers/axios-with-proxy.test.js @@ -0,0 +1,169 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; + +describe('Custom default axios with proxy', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should have two interceptors by default', async () => { + const axios = (await import('./axios-with-proxy.js')).default; + const requestInterceptors = axios.interceptors.request.handlers; + + expect(requestInterceptors.length).toBe(2); + }); + + it('should have default interceptors in a certain order', async () => { + const axios = (await import('./axios-with-proxy.js')).default; + + const requestInterceptors = axios.interceptors.request.handlers; + const firstRequestInterceptor = requestInterceptors[0]; + const secondRequestInterceptor = requestInterceptors[1]; + + expect(firstRequestInterceptor.fulfilled.name).toBe('skipProxyIfInNoProxy'); + expect(secondRequestInterceptor.fulfilled.name).toBe('removeBaseUrlForAbsoluteUrls'); + }); + + it('should throw with invalid url (consisting of path alone)', async () => { + const axios = (await import('./axios-with-proxy.js')).default; + + await expect(() => axios('/just-a-path')).rejects.toThrowError('Invalid URL'); + }); + + describe('with skipProxyIfInNoProxy interceptor', () => { + let appConfig, axios; + beforeEach(async() => { + appConfig = (await import('../config/app.js')).default; + + vi.spyOn(appConfig, 'httpProxy', 'get').mockReturnValue('http://proxy.automatisch.io'); + vi.spyOn(appConfig, 'httpsProxy', 'get').mockReturnValue('http://proxy.automatisch.io'); + vi.spyOn(appConfig, 'noProxy', 'get').mockReturnValue('name.tld,automatisch.io'); + + axios = (await import('./axios-with-proxy.js')).default; + }); + + it('should skip proxy for hosts in no_proxy environment variable', async () => { + const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled; + + const mockRequestConfig = { + ...axios.defaults, + baseURL: 'https://automatisch.io' + }; + + const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig); + + expect(interceptedRequestConfig.httpAgent).toBeUndefined(); + expect(interceptedRequestConfig.httpsAgent).toBeUndefined(); + expect(interceptedRequestConfig.proxy).toBe(false); + }); + + it('should not skip proxy for hosts not in no_proxy environment variable', async () => { + const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled; + + const mockRequestConfig = { + ...axios.defaults, + // beware the intentional typo! + baseURL: 'https://automatish.io' + }; + + const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig); + + expect(interceptedRequestConfig.httpAgent).toBeDefined(); + expect(interceptedRequestConfig.httpsAgent).toBeDefined(); + expect(interceptedRequestConfig.proxy).toBe(false); + }); + }); + + describe('with removeBaseUrlForAbsoluteUrls interceptor', () => { + let axios; + beforeEach(async() => { + axios = (await import('./axios-with-proxy.js')).default; + }); + + it('should trim the baseUrl from absolute urls', async () => { + const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled; + + const mockRequestConfig = { + ...axios.defaults, + url: 'https://automatisch.io/path' + }; + + const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig); + + expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io'); + expect(interceptedRequestConfig.url).toBe('/path'); + }); + + it('should not mutate separate baseURL and urls', async () => { + const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled; + + const mockRequestConfig = { + ...axios.defaults, + baseURL: 'https://automatisch.io', + url: '/path?query=1' + }; + + const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig); + + expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io'); + expect(interceptedRequestConfig.url).toBe('/path?query=1'); + }); + + it('should not strip querystring from url', async () => { + const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled; + + const mockRequestConfig = { + ...axios.defaults, + url: 'https://automatisch.io/path?query=1' + }; + + const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig); + + expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io'); + expect(interceptedRequestConfig.url).toBe('/path?query=1'); + }); + }); + + describe('with extra requestInterceptors', () => { + it('should apply extra request interceptors in the middle', async () => { + const { createInstance } = await import('./axios-with-proxy.js'); + + const interceptor = (config) => { + config.test = true; + return config; + } + + const instance = createInstance({}, { + requestInterceptor: [ + interceptor + ] + }); + const requestInterceptors = instance.interceptors.request.handlers; + const customInterceptor = requestInterceptors[1].fulfilled; + + expect(requestInterceptors.length).toBe(3); + await expect(customInterceptor({})).resolves.toStrictEqual({ test: true }); + }); + + it('should work with a custom interceptor setting a baseURL and a request to path', async () => { + const { createInstance } = await import('./axios-with-proxy.js'); + + const interceptor = (config) => { + config.baseURL = 'http://localhost'; + return config; + } + + const instance = createInstance({}, { + requestInterceptor: [ + interceptor + ] + }); + + try { + await instance.get('/just-a-path'); + } catch (error) { + expect(error.config.baseURL).toBe('http://localhost'); + expect(error.config.url).toBe('/just-a-path'); + } + }) + }); +}); diff --git a/packages/backend/src/helpers/billing/index.ee.js b/packages/backend/src/helpers/billing/index.ee.js new file mode 100644 index 0000000..091598a --- /dev/null +++ b/packages/backend/src/helpers/billing/index.ee.js @@ -0,0 +1,18 @@ +import appConfig from '../../config/app.js'; +import paddleClient from './paddle.ee.js'; +import paddlePlans from './plans.ee.js'; +import webhooks from './webhooks.ee.js'; + +const paddleInfo = { + sandbox: appConfig.isProd ? false : true, + vendorId: appConfig.paddleVendorId, +}; + +const billing = { + paddleClient, + paddlePlans, + paddleInfo, + webhooks, +}; + +export default billing; diff --git a/packages/backend/src/helpers/billing/paddle.ee.js b/packages/backend/src/helpers/billing/paddle.ee.js new file mode 100644 index 0000000..e4bed3f --- /dev/null +++ b/packages/backend/src/helpers/billing/paddle.ee.js @@ -0,0 +1,53 @@ +// TODO: replace with axios-with-proxy when needed +import axios from 'axios'; +import appConfig from '../../config/app.js'; +import { DateTime } from 'luxon'; + +const PADDLE_VENDOR_URL = appConfig.isDev + ? 'https://sandbox-vendors.paddle.com' + : 'https://vendors.paddle.com'; + +const axiosInstance = axios.create({ baseURL: PADDLE_VENDOR_URL }); + +const getSubscription = async (subscriptionId) => { + const data = { + vendor_id: appConfig.paddleVendorId, + vendor_auth_code: appConfig.paddleVendorAuthCode, + subscription_id: subscriptionId, + }; + + const response = await axiosInstance.post( + '/api/2.0/subscription/users', + data + ); + const subscription = response.data.response[0]; + return subscription; +}; + +const getInvoices = async (subscriptionId) => { + // TODO: iterate over previous subscriptions and include their invoices + const data = { + vendor_id: appConfig.paddleVendorId, + vendor_auth_code: appConfig.paddleVendorAuthCode, + subscription_id: subscriptionId, + is_paid: 1, + from: DateTime.now().minus({ years: 3 }).toISODate(), + to: DateTime.now().plus({ days: 3 }).toISODate(), + }; + + const response = await axiosInstance.post( + '/api/2.0/subscription/payments', + data + ); + + const invoices = response.data.response; + + return invoices; +}; + +const paddleClient = { + getSubscription, + getInvoices, +}; + +export default paddleClient; diff --git a/packages/backend/src/helpers/billing/plans.ee.js b/packages/backend/src/helpers/billing/plans.ee.js new file mode 100644 index 0000000..4104698 --- /dev/null +++ b/packages/backend/src/helpers/billing/plans.ee.js @@ -0,0 +1,29 @@ +import appConfig from '../../config/app.js'; + +const testPlans = [ + { + name: '10k - monthly', + limit: '10,000', + quota: 10000, + price: '€20', + productId: '47384', + }, +]; + +const prodPlans = [ + { + name: '10k - monthly', + limit: '10,000', + quota: 10000, + price: '€20', + productId: '826658', + }, +]; + +const plans = appConfig.isProd ? prodPlans : testPlans; + +export function getPlanById(id) { + return plans.find((plan) => plan.productId === id); +} + +export default plans; diff --git a/packages/backend/src/helpers/billing/webhooks.ee.js b/packages/backend/src/helpers/billing/webhooks.ee.js new file mode 100644 index 0000000..19cc194 --- /dev/null +++ b/packages/backend/src/helpers/billing/webhooks.ee.js @@ -0,0 +1,80 @@ +import Subscription from '../../models/subscription.ee.js'; +import Billing from './index.ee.js'; + +const handleSubscriptionCreated = async (request) => { + const subscription = await Subscription.query().insertAndFetch( + formatSubscription(request) + ); + await subscription + .$relatedQuery('usageData') + .insert(formatUsageData(request)); +}; + +const handleSubscriptionUpdated = async (request) => { + await Subscription.query() + .findOne({ + paddle_subscription_id: request.body.subscription_id, + }) + .patch(formatSubscription(request)); +}; + +const handleSubscriptionCancelled = async (request) => { + const subscription = await Subscription.query().findOne({ + paddle_subscription_id: request.body.subscription_id, + }); + + await subscription.$query().patchAndFetch(formatSubscription(request)); +}; + +const handleSubscriptionPaymentSucceeded = async (request) => { + const subscription = await Subscription.query() + .findOne({ + paddle_subscription_id: request.body.subscription_id, + }) + .throwIfNotFound(); + + const remoteSubscription = await Billing.paddleClient.getSubscription( + Number(subscription.paddleSubscriptionId) + ); + + await subscription.$query().patch({ + nextBillAmount: remoteSubscription.next_payment.amount.toFixed(2), + nextBillDate: remoteSubscription.next_payment.date, + lastBillDate: remoteSubscription.last_payment.date, + }); + + await subscription + .$relatedQuery('usageData') + .insert(formatUsageData(request)); +}; + +const formatSubscription = (request) => { + return { + userId: JSON.parse(request.body.passthrough).id, + paddleSubscriptionId: request.body.subscription_id, + paddlePlanId: request.body.subscription_plan_id, + cancelUrl: request.body.cancel_url, + updateUrl: request.body.update_url, + status: request.body.status, + nextBillDate: request.body.next_bill_date, + nextBillAmount: request.body.unit_price, + cancellationEffectiveDate: request.body.cancellation_effective_date, + }; +}; + +const formatUsageData = (request) => { + return { + userId: JSON.parse(request.body.passthrough).id, + consumedTaskCount: 0, + nextResetAt: request.body.next_bill_date, + }; +}; + +const webhooks = { + handleSubscriptionCreated, + handleSubscriptionUpdated, + handleSubscriptionCancelled, + handleSubscriptionPaymentSucceeded, +}; + +export default webhooks; diff --git a/packages/backend/src/helpers/check-is-cloud.js b/packages/backend/src/helpers/check-is-cloud.js new file mode 100644 index 0000000..f0b93b5 --- /dev/null +++ b/packages/backend/src/helpers/check-is-cloud.js @@ -0,0 +1,11 @@ +import appConfig from '../config/app.js'; + +export const checkIsCloud = async (request, response, next) => { + if (appConfig.isCloud) { + next(); + } else { + return response.status(404).end(); + } +}; + +export default checkIsCloud; diff --git a/packages/backend/src/helpers/check-is-enterprise.js b/packages/backend/src/helpers/check-is-enterprise.js new file mode 100644 index 0000000..0180eea --- /dev/null +++ b/packages/backend/src/helpers/check-is-enterprise.js @@ -0,0 +1,9 @@ +import { hasValidLicense } from './license.ee.js'; + +export const checkIsEnterprise = async (request, response, next) => { + if (await hasValidLicense()) { + next(); + } else { + return response.status(404).end(); + } +}; diff --git a/packages/backend/src/helpers/check-worker-readiness.js b/packages/backend/src/helpers/check-worker-readiness.js new file mode 100644 index 0000000..e1ae0a7 --- /dev/null +++ b/packages/backend/src/helpers/check-worker-readiness.js @@ -0,0 +1,11 @@ +import Redis from 'ioredis'; +import logger from './logger.js'; +import redisConfig from '../config/redis.js'; + +const redisClient = new Redis(redisConfig); + +redisClient.on('ready', () => { + logger.info(`Workers are ready!`); + + redisClient.disconnect(); +}); diff --git a/packages/backend/src/helpers/compile-email.ee.js b/packages/backend/src/helpers/compile-email.ee.js new file mode 100644 index 0000000..305f87e --- /dev/null +++ b/packages/backend/src/helpers/compile-email.ee.js @@ -0,0 +1,15 @@ +import path from 'path'; +import fs from 'fs'; +import handlebars from 'handlebars'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const compileEmail = (emailPath, replacements = {}) => { + const filePath = path.join(__dirname, `../views/emails/${emailPath}.hbs`); + const source = fs.readFileSync(filePath, 'utf-8').toString(); + const template = handlebars.compile(source); + return template(replacements); +}; + +export default compileEmail; diff --git a/packages/backend/src/helpers/compute-parameters.js b/packages/backend/src/helpers/compute-parameters.js new file mode 100644 index 0000000..ef22596 --- /dev/null +++ b/packages/backend/src/helpers/compute-parameters.js @@ -0,0 +1,165 @@ +import get from 'lodash.get'; + +const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[^.}{]+)+}})/g; + +function getParameterEntries(parameters) { + return Object.entries(parameters); +} + +function getFieldByKey(key, fields = []) { + return fields.find((field) => field.key === key); +}; + +function getParameterValueType(parameterKey, fields) { + const field = getFieldByKey(parameterKey, fields); + const defaultValueType = 'string'; + + return field?.valueType || defaultValueType; +} + +function computeParameterEntries(parameterEntries, fields, executionSteps) { + const defaultComputedParameters = {}; + return parameterEntries.reduce((result, [key, value]) => { + const parameterComputedValue = computeParameter(key, value, fields, executionSteps); + + return { + ...result, + [key]: parameterComputedValue, + } + }, defaultComputedParameters); +} + +function shouldAutoParse(key, fields) { + const parameterValueType = getParameterValueType(key, fields); + const shouldAutoParse = parameterValueType === 'parse'; + + return shouldAutoParse; +} + +function computeParameter(key, value, fields, executionSteps) { + if (typeof value === 'string') { + const computedStringParameter = computeStringParameter(key, value, fields, executionSteps); + return computedStringParameter; + } + + if (Array.isArray(value)) { + const computedArrayParameter = computeArrayParameter(key, value, fields, executionSteps); + return computedArrayParameter; + } + + return value; +} + +function splitByVariable(stringValue) { + const parts = stringValue.split(variableRegExp); + + return parts; +} + +function isVariable(stringValue) { + return stringValue.match(variableRegExp); +} + +function splitVariableByStepIdAndKeyPath(variableValue) { + const stepIdAndKeyPath = variableValue.replace(/{{step.|}}/g, ''); + const [stepId, ...keyPaths] = stepIdAndKeyPath.split('.'); + const keyPath = keyPaths.join('.'); + + return { + stepId, + keyPath + } +} + +function getVariableStepId(variableValue) { + const { stepId } = splitVariableByStepIdAndKeyPath(variableValue); + + return stepId; +} + +function getVariableKeyPath(variableValue) { + const { keyPath } = splitVariableByStepIdAndKeyPath(variableValue); + + return keyPath +} + +function getVariableExecutionStep(variableValue, executionSteps) { + const stepId = getVariableStepId(variableValue); + + const executionStep = executionSteps.find((executionStep) => { + return executionStep.stepId === stepId; + }); + + return executionStep; +} + +function computeVariable(variable, executionSteps) { + const keyPath = getVariableKeyPath(variable); + const executionStep = getVariableExecutionStep(variable, executionSteps); + const data = executionStep?.dataOut; + const computedVariable = get(data, keyPath); + + /** + * Inline both arrays and objects. Otherwise, variables resolving to + * them would be resolved as `[object Object]` or lose their shape. + */ + if (typeof computedVariable === 'object') { + return JSON.stringify(computedVariable); + } + + return computedVariable; +} + +function autoParseComputedVariable(computedVariable) { + // challenge the input to see if it is stringified object or array + try { + const parsedValue = JSON.parse(computedVariable); + + if (typeof parsedValue === 'number') { + throw new Error('Use original unparsed value.'); + } + + return parsedValue; + } catch (error) { + return computedVariable; + } +} + +function computeStringParameter(key, stringValue, fields, executionSteps) { + const parts = splitByVariable(stringValue); + + const computedValue = parts + .map((part) => { + const variable = isVariable(part); + + if (variable) { + return computeVariable(part, executionSteps); + } + + return part; + }) + .join(''); + + const autoParse = shouldAutoParse(key, fields); + if (autoParse) { + const autoParsedValue = autoParseComputedVariable(computedValue); + + return autoParsedValue; + } + + return computedValue; +} + +function computeArrayParameter(key, arrayValue, fields = [], executionSteps) { + return arrayValue.map((item) => { + const itemFields = fields.find((field) => field.key === key)?.fields; + + return computeParameters(item, itemFields, executionSteps); + }); +} + +export default function computeParameters(parameters, fields, executionSteps) { + const parameterEntries = getParameterEntries(parameters); + + return computeParameterEntries(parameterEntries, fields, executionSteps); +} diff --git a/packages/backend/src/helpers/compute-parameters.test.js b/packages/backend/src/helpers/compute-parameters.test.js new file mode 100644 index 0000000..c45a680 --- /dev/null +++ b/packages/backend/src/helpers/compute-parameters.test.js @@ -0,0 +1,487 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { createExecutionStep } from '../../test/factories/execution-step.js'; +import { createDropdownArgument, createDynamicArgument, createStringArgument } from '../../test/factories/app.js'; +import computeParameters from './compute-parameters.js'; + +const computeVariable = (stepId, path) => `{{step.${stepId}.${path}}}`; + +describe('Compute parameters helper', () => { + let executionStepOne, + executionStepTwo, + executionStepThree, + executionSteps; + + beforeEach(async () => { + executionStepOne = await createExecutionStep({ + dataOut: { + step1Key1: 'plain text value for step1Key1', + // eslint-disable-next-line no-loss-of-precision + step1Key2: 1267963836502380617, + step1Key3: '1267963836502380617', + step1Key4: { + step1Key4ChildKey1: 'plain text value for step1Key3ChildKey1', + // eslint-disable-next-line no-loss-of-precision + step1Key4ChildKey2: 1267963836502380617, + step1Key4ChildKey3: '3650238061712679638', + step1Key4ChildKey4: ["value1", "value2"], + } + } + }); + + executionStepTwo = await createExecutionStep({ + dataOut: { + step2Key1: 'plain text value for step2Key1', + // eslint-disable-next-line no-loss-of-precision + step2Key2: 6502380617126796383, + step2Key3: '6502380617126796383', + step2Key4: { + step2Key4ChildKey1: 'plain text value for step2Key3ChildKey1', + // eslint-disable-next-line no-loss-of-precision + step2Key4ChildKey2: 6502380617126796383, + step2Key4ChildKey3: '6502380617312679638', + step2Key4ChildKey4: ["value1", "value2"], + } + } + }); + + executionStepThree = await createExecutionStep({ + dataOut: { + step3Key1: 'plain text value for step3Key1', + // eslint-disable-next-line no-loss-of-precision + step3Key2: 123123, + step3Key3: '123123', + step3Key4: { + step3Key4ChildKey1: 'plain text value for step3Key3ChildKey1', + // eslint-disable-next-line no-loss-of-precision + step3Key4ChildKey2: 123123, + step3Key4ChildKey3: '123123', + step3Key4ChildKey4: ["value1", "value2"], + } + } + }); + + executionSteps = [executionStepOne, executionStepTwo, executionStepThree]; + }); + + it('should resolve with parameters having no corresponding steps', () => { + const parameters = { + key1: `${computeVariable('non-existent-step-id', 'non-existent-key')}`, + key2: `"${computeVariable('non-existent-step-id', 'non-existent-key')}" is the value for non-existent-key`, + }; + + const computedParameters = computeParameters(parameters, [], executionSteps); + const expectedParameters = { + key1: '', + key2: '"" is the value for non-existent-key', + } + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + describe('with no parameters', () => { + it('should resolve empty object', () => { + const parameters = {}; + + const computedParameters = computeParameters(parameters, [], executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + }); + }); + + describe('with string parameters', () => { + let stepArguments; + beforeEach(() => { + stepArguments = [ + createStringArgument({ + key: 'key1', + }), + ]; + }); + + it('should resolve as-is without variables', () => { + const parameters = { + key1: 'plain text', + key2: 'plain text', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + }); + + it('should preserve leading and trailing spaces', () => { + const parameters = { + key1: ' plain text ', + key2: 'plain text ', + key3: ' plain text', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + }); + + it('should compute variables correctly', () => { + const parameters = { + key1: `static text ${computeVariable(executionStepOne.stepId, 'step1Key1')}`, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: `static text plain text value for step1Key1`, + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + describe('with variables containing JSON', () => { + describe('without explicit valueType defined', () => { + let stepArguments; + beforeEach(() => { + stepArguments = [ + createStringArgument({ + key: 'key1', + }), + ]; + }); + + it('should resolve text + JSON value as-is', () => { + const parameters = { + key1: 'prepended text {"key": "value"} ', + }; + + const computedParameters = computeParameters(parameters, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + }); + + it('should resolve stringified JSON parsed', () => { + const parameters = { + key1: '{"key1": "plain text", "key2": "119007199254740999"}', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: '{"key1": "plain text", "key2": "119007199254740999"}', + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should handle arrays at root level', () => { + const parameters = { + key1: '["value1", "value2"]', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: '["value1", "value2"]', + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should handle arrays in nested level', () => { + const parameters = { + key1: '{"items": ["value1", "value2"]}', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: '{"items": ["value1", "value2"]}', + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should compute mix variables correctly', () => { + const parameters = { + key1: `another static text ${computeVariable(executionStepThree.stepId, 'step3Key4.step3Key4ChildKey4')}`, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: `another static text ["value1","value2"]`, + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should compute variables correctly', () => { + const parameters = { + key1: `${computeVariable(executionStepThree.stepId, 'step3Key4.step3Key4ChildKey4')}`, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: '["value1","value2"]', + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should not parse non-primitives in nested arrays', () => { + const stepArguments = [ + createDynamicArgument({ + key: 'inputs', + }) + ]; + + const parameters = { + inputs: [ + { + key: 'person', + value: '{ "name": "John Doe", "age": 32 }', + } + ], + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + inputs: [ + { + key: 'person', + value: '{ "name": "John Doe", "age": 32 }', + } + ], + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + }); + + describe(`with valueType as 'parse'`, () => { + let stepArguments; + beforeEach(() => { + stepArguments = [ + createStringArgument({ + key: 'key1', + valueType: 'parse', + }), + ]; + }); + + it('should resolve text + JSON value as-is', () => { + const parameters = { + key1: 'prepended text {"key": "value"} ', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + }); + + it('should resolve stringified JSON parsed', () => { + const parameters = { + key1: '{"key1": "plain text", "key2": "119007199254740999"}', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: { + key1: 'plain text', + key2: '119007199254740999', + }, + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should handle arrays at root level', () => { + const parameters = { + key1: '["value1", "value2"]', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: ['value1', 'value2'], + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should handle arrays in nested level', () => { + const parameters = { + key1: '{"items": ["value1", "value2"]}', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: { + items: ['value1', 'value2'], + } + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should compute and parse mix variables correctly', () => { + const parameters = { + key1: `another static text ${computeVariable(executionStepThree.stepId, 'step3Key4.step3Key4ChildKey4')}`, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: `another static text ["value1","value2"]`, + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should compute and parse variables correctly', () => { + const parameters = { + key1: `${computeVariable(executionStepThree.stepId, 'step3Key4.step3Key4ChildKey4')}`, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: ["value1", "value2"], + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it('should compute and parse variables in nested arrays correctly', () => { + const stepArguments = [ + createDynamicArgument({ + key: 'inputs', + fields: [ + { + label: 'Key', + key: 'key', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + required: true, + variables: true, + valueType: 'parse', + } + ], + }) + ]; + + const parameters = { + inputs: [ + { + key: 'person', + value: '{ "name": "John Doe", "age": 32 }', + } + ], + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + inputs: [ + { + key: 'person', + value: { + name: 'John Doe', + age: 32, + } + } + ], + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + }); + }); + }); + + describe('with number parameters', () => { + let stepArguments; + beforeEach(() => { + stepArguments = [ + createStringArgument({ + key: 'key1', + }), + createStringArgument({ + key: 'key2', + }), + ]; + }); + + it('should resolve number larger than MAX_SAFE_INTEGER correctly', () => { + const parameters = { + // eslint-disable-next-line no-loss-of-precision + key1: 119007199254740999, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + expect(computedParameters.key1 > Number.MAX_SAFE_INTEGER).toBe(true); + }); + + it('should resolve stringified number as-is', () => { + const parameters = { + key1: '119007199254740999', + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + }); + + it('should compute variables with int values correctly', () => { + const parameters = { + key1: `another static text ${computeVariable(executionStepThree.stepId, 'step3Key2')}`, + key2: `${computeVariable(executionStepThree.stepId, 'step3Key3')}` + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + key1: `another static text 123123`, + key2: `123123` + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + + it.todo('should compute variables with bigint values correctly', () => { + const parameters = { + key1: `another static text ${computeVariable(executionStepTwo.stepId, 'step2Key2')}`, + key2: `${computeVariable(executionStepTwo.stepId, 'step2Key3')}` + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + const expectedParameters = { + // The expected `key2` is computed wrongly. + key1: `another static text 6502380617126796383`, + key2: `6502380617126796383` + }; + + expect(computedParameters).toStrictEqual(expectedParameters); + }); + }); + + describe('with boolean parameters', () => { + let stepArguments; + beforeEach(() => { + stepArguments = [ + createDropdownArgument({ + key: 'key1', + }), + createDropdownArgument({ + key: 'key2', + }), + ]; + }); + + it('should resolve boolean as-is', () => { + const parameters = { + key1: true, + key2: false, + }; + + const computedParameters = computeParameters(parameters, stepArguments, executionSteps); + + expect(computedParameters).toStrictEqual(parameters); + expect(computedParameters.key1).toBe(true); + expect(computedParameters.key2).toBe(false); + }); + }); +}); diff --git a/packages/backend/src/helpers/create-auth-token-by-user-id.js b/packages/backend/src/helpers/create-auth-token-by-user-id.js new file mode 100644 index 0000000..2b82440 --- /dev/null +++ b/packages/backend/src/helpers/create-auth-token-by-user-id.js @@ -0,0 +1,21 @@ +import crypto from 'crypto'; +import User from '../models/user.js'; +import AccessToken from '../models/access-token.js'; + +const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds + +const createAuthTokenByUserId = async (userId, samlSessionId) => { + const user = await User.query().findById(userId).throwIfNotFound(); + const token = await crypto.randomBytes(48).toString('hex'); + + await AccessToken.query().insert({ + token, + samlSessionId, + userId: user.id, + expiresIn: TOKEN_EXPIRES_IN, + }); + + return token; +}; + +export default createAuthTokenByUserId; diff --git a/packages/backend/src/helpers/create-bull-board-handler.js b/packages/backend/src/helpers/create-bull-board-handler.js new file mode 100644 index 0000000..03f949b --- /dev/null +++ b/packages/backend/src/helpers/create-bull-board-handler.js @@ -0,0 +1,43 @@ +import { ExpressAdapter } from '@bull-board/express'; +import { createBullBoard } from '@bull-board/api'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js'; +import flowQueue from '../queues/flow.js'; +import triggerQueue from '../queues/trigger.js'; +import actionQueue from '../queues/action.js'; +import emailQueue from '../queues/email.js'; +import deleteUserQueue from '../queues/delete-user.ee.js'; +import removeCancelledSubscriptionsQueue from '../queues/remove-cancelled-subscriptions.ee.js'; +import appConfig from '../config/app.js'; + +const serverAdapter = new ExpressAdapter(); + +const queues = [ + new BullMQAdapter(flowQueue), + new BullMQAdapter(triggerQueue), + new BullMQAdapter(actionQueue), + new BullMQAdapter(emailQueue), + new BullMQAdapter(deleteUserQueue), +]; + +if (appConfig.isCloud) { + queues.push(new BullMQAdapter(removeCancelledSubscriptionsQueue)); +} + +const shouldEnableBullDashboard = () => { + return ( + appConfig.enableBullMQDashboard && + appConfig.bullMQDashboardUsername && + appConfig.bullMQDashboardPassword + ); +}; + +const createBullBoardHandler = async (serverAdapter) => { + if (!shouldEnableBullDashboard) return; + + createBullBoard({ + queues, + serverAdapter, + }); +}; + +export { createBullBoardHandler, serverAdapter }; diff --git a/packages/backend/src/helpers/define-action.js b/packages/backend/src/helpers/define-action.js new file mode 100644 index 0000000..fc3b45e --- /dev/null +++ b/packages/backend/src/helpers/define-action.js @@ -0,0 +1,3 @@ +export default function defineAction(actionDefinition) { + return actionDefinition; +} diff --git a/packages/backend/src/helpers/define-app.js b/packages/backend/src/helpers/define-app.js new file mode 100644 index 0000000..3630f47 --- /dev/null +++ b/packages/backend/src/helpers/define-app.js @@ -0,0 +1,3 @@ +export default function defineApp(appDefinition) { + return appDefinition; +} diff --git a/packages/backend/src/helpers/define-trigger.js b/packages/backend/src/helpers/define-trigger.js new file mode 100644 index 0000000..8fde89e --- /dev/null +++ b/packages/backend/src/helpers/define-trigger.js @@ -0,0 +1,28 @@ +import logger from './logger.js'; + +export default function defineTrigger(triggerDefinition) { + const isWebhookOrPoll = + triggerDefinition.pollInterval || triggerDefinition.type === 'webhook'; + + const schedulerTriggers = [ + 'everyNMinutes', + 'everyHour', + 'everyDay', + 'everyWeek', + 'everyMonth', + ]; + + const isSchedulerTrigger = schedulerTriggers.includes(triggerDefinition.key); + + const haveValidTriggerType = isWebhookOrPoll || isSchedulerTrigger; + + if (!haveValidTriggerType) { + logger.info(triggerDefinition); + + throw new Error( + `Trigger must have a poll interval or be a webhook for ${triggerDefinition.key}` + ); + } + + return triggerDefinition; +} diff --git a/packages/backend/src/helpers/delay-as-milliseconds.js b/packages/backend/src/helpers/delay-as-milliseconds.js new file mode 100644 index 0000000..c657647 --- /dev/null +++ b/packages/backend/src/helpers/delay-as-milliseconds.js @@ -0,0 +1,19 @@ +import delayForAsMilliseconds from './delay-for-as-milliseconds.js'; +import delayUntilAsMilliseconds from './delay-until-as-milliseconds.js'; + +const delayAsMilliseconds = (eventKey, computedParameters) => { + let delayDuration = 0; + + if (eventKey === 'delayFor') { + const { delayForUnit, delayForValue } = computedParameters; + + delayDuration = delayForAsMilliseconds(delayForUnit, Number(delayForValue)); + } else if (eventKey === 'delayUntil') { + const { delayUntil } = computedParameters; + delayDuration = delayUntilAsMilliseconds(delayUntil); + } + + return delayDuration; +}; + +export default delayAsMilliseconds; diff --git a/packages/backend/src/helpers/delay-for-as-milliseconds.js b/packages/backend/src/helpers/delay-for-as-milliseconds.js new file mode 100644 index 0000000..e3ae586 --- /dev/null +++ b/packages/backend/src/helpers/delay-for-as-milliseconds.js @@ -0,0 +1,16 @@ +const delayAsMilliseconds = (delayForUnit, delayForValue) => { + switch (delayForUnit) { + case 'minutes': + return delayForValue * 60 * 1000; + case 'hours': + return delayForValue * 60 * 60 * 1000; + case 'days': + return delayForValue * 24 * 60 * 60 * 1000; + case 'weeks': + return delayForValue * 7 * 24 * 60 * 60 * 1000; + default: + return 0; + } +}; + +export default delayAsMilliseconds; diff --git a/packages/backend/src/helpers/delay-until-as-milliseconds.js b/packages/backend/src/helpers/delay-until-as-milliseconds.js new file mode 100644 index 0000000..4508fa8 --- /dev/null +++ b/packages/backend/src/helpers/delay-until-as-milliseconds.js @@ -0,0 +1,8 @@ +const delayUntilAsMilliseconds = (delayUntil) => { + const delayUntilDate = new Date(delayUntil); + const now = new Date(); + + return delayUntilDate.getTime() - now.getTime(); +}; + +export default delayUntilAsMilliseconds; diff --git a/packages/backend/src/helpers/error-handler.js b/packages/backend/src/helpers/error-handler.js new file mode 100644 index 0000000..7dcac4a --- /dev/null +++ b/packages/backend/src/helpers/error-handler.js @@ -0,0 +1,84 @@ +import logger from './logger.js'; +import objection from 'objection'; +import * as Sentry from './sentry.ee.js'; +const { + NotFoundError, + DataError, + ForeignKeyViolationError, + ValidationError, + UniqueViolationError, +} = objection; +import NotAuthorizedError from '../errors/not-authorized.js'; +import HttpError from '../errors/http.js'; +import { + renderObjectionError, + renderUniqueViolationError, +} from './renderer.js'; + +// Do not remove `next` argument as the function signature will not fit for an error handler middleware +// eslint-disable-next-line no-unused-vars +const errorHandler = (error, request, response, next) => { + if (error.message === 'Not Found' || error instanceof NotFoundError) { + response.status(404).end(); + } + + if (notFoundAppError(error)) { + response.status(404).end(); + } + + if (error instanceof ValidationError) { + renderObjectionError(response, error, 422); + } + + if (error instanceof UniqueViolationError) { + renderUniqueViolationError(response, error); + } + + if (error instanceof ForeignKeyViolationError) { + response.status(500).end(); + } + + if (error instanceof DataError) { + response.status(400).end(); + } + + if (error instanceof HttpError) { + const httpErrorPayload = { + errors: JSON.parse(error.message), + meta: { + type: 'HttpError', + }, + }; + + response.status(422).json(httpErrorPayload); + } + + if (error instanceof NotAuthorizedError) { + response.status(403).end(); + } + + const statusCode = error.statusCode || 500; + + logger.error(request.method + ' ' + request.url + ' ' + statusCode); + logger.error(error.stack); + + Sentry.captureException(error, { + tags: { rest: true }, + extra: { + url: request?.url, + method: request?.method, + params: request?.params, + }, + }); + + response.status(statusCode).end(); +}; + +const notFoundAppError = (error) => { + return ( + error.message.includes('An application with the') && + error.message.includes("key couldn't be found.") + ); +}; + +export default errorHandler; diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js new file mode 100644 index 0000000..05b238d --- /dev/null +++ b/packages/backend/src/helpers/export-flow.js @@ -0,0 +1,45 @@ +import Crypto from 'crypto'; + +const exportFlow = async (flow) => { + const steps = await flow.$relatedQuery('steps'); + + const newFlowId = Crypto.randomUUID(); + const stepIdMap = Object.fromEntries( + steps.map((step) => [step.id, Crypto.randomUUID()]) + ); + + const exportedFlow = { + id: newFlowId, + name: flow.name, + steps: steps.map((step) => ({ + id: stepIdMap[step.id], + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: updateParameters(step.parameters, stepIdMap), + position: step.position, + webhookPath: step.webhookPath?.replace(flow.id, newFlowId), + })), + }; + + return exportedFlow; +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default exportFlow; diff --git a/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.js b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.js new file mode 100644 index 0000000..2349b60 --- /dev/null +++ b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.js @@ -0,0 +1,64 @@ +import User from '../models/user.js'; +import Identity from '../models/identity.ee.js'; + +const getUser = (user, providerConfig) => ({ + name: user[providerConfig.firstnameAttributeName], + surname: user[providerConfig.surnameAttributeName], + id: user.nameID, + email: user[providerConfig.emailAttributeName], + role: user[providerConfig.roleAttributeName], +}); + +const findOrCreateUserBySamlIdentity = async ( + userIdentity, + samlAuthProvider +) => { + const mappedUser = getUser(userIdentity, samlAuthProvider); + const identity = await Identity.query().findOne({ + remote_id: mappedUser.id, + provider_type: 'saml', + }); + + if (identity) { + const user = await identity.$relatedQuery('user'); + + return user; + } + + const mappedRoles = Array.isArray(mappedUser.role) + ? mappedUser.role + : [mappedUser.role]; + + const samlAuthProviderRoleMapping = await samlAuthProvider + .$relatedQuery('roleMappings') + .whereIn('remote_role_name', mappedRoles) + .limit(1) + .first(); + + const createdUser = await User.query() + .insertGraph( + { + fullName: [mappedUser.name, mappedUser.surname] + .filter(Boolean) + .join(' '), + email: mappedUser.email, + roleId: + samlAuthProviderRoleMapping?.roleId || samlAuthProvider.defaultRoleId, + identities: [ + { + remoteId: mappedUser.id, + providerId: samlAuthProvider.id, + providerType: 'saml', + }, + ], + }, + { + relate: ['identities'], + } + ) + .returning('*'); + + return createdUser; +}; + +export default findOrCreateUserBySamlIdentity; diff --git a/packages/backend/src/helpers/get-app.js b/packages/backend/src/helpers/get-app.js new file mode 100644 index 0000000..5e23fb1 --- /dev/null +++ b/packages/backend/src/helpers/get-app.js @@ -0,0 +1,94 @@ +import path, { join } from 'path'; +import fs from 'node:fs'; +import omit from 'lodash/omit.js'; +import cloneDeep from 'lodash/cloneDeep.js'; +import addAuthenticationSteps from './add-authentication-steps.js'; +import addReconnectionSteps from './add-reconnection-steps.js'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const apps = fs + .readdirSync(path.resolve(__dirname, `../apps/`), { withFileTypes: true }) + .reduce((apps, dirent) => { + if (!dirent.isDirectory()) return apps; + + apps[dirent.name] = import( + pathToFileURL(join(__dirname, '../apps', dirent.name, 'index.js')) + ); + + return apps; + }, {}); + +async function getAppDefaultExport(appKey) { + if (!Object.prototype.hasOwnProperty.call(apps, appKey)) { + throw new Error( + `An application with the "${appKey}" key couldn't be found.` + ); + } + + return (await apps[appKey]).default; +} + +function stripFunctions(data) { + return JSON.parse(JSON.stringify(data)); +} + +const getApp = async (appKey, stripFuncs = true) => { + let appData = cloneDeep(await getAppDefaultExport(appKey)); + + if (appData.auth) { + appData = addAuthenticationSteps(appData); + appData = addReconnectionSteps(appData); + } + + appData.triggers = appData?.triggers?.map((trigger) => { + return addStaticSubsteps('trigger', appData, trigger); + }); + + appData.actions = appData?.actions?.map((action) => { + return addStaticSubsteps('action', appData, action); + }); + + if (stripFuncs) { + return stripFunctions(appData); + } + + return appData; +}; + +const chooseConnectionStep = { + key: 'chooseConnection', + name: 'Choose connection', +}; + +const testStep = (stepType) => { + return { + key: 'testStep', + name: stepType === 'trigger' ? 'Test trigger' : 'Test action', + }; +}; + +const addStaticSubsteps = (stepType, appData, step) => { + const computedStep = omit(step, ['arguments']); + + computedStep.substeps = []; + + if (appData.supportsConnections && step.supportsConnections !== false) { + computedStep.substeps.push(chooseConnectionStep); + } + + if (step.arguments) { + computedStep.substeps.push({ + key: 'chooseTrigger', + name: stepType === 'trigger' ? 'Set up a trigger' : 'Set up action', + arguments: step.arguments, + }); + } + + computedStep.substeps.push(testStep(stepType)); + + return computedStep; +}; + +export default getApp; diff --git a/packages/backend/src/helpers/global-variable.js b/packages/backend/src/helpers/global-variable.js new file mode 100644 index 0000000..843499f --- /dev/null +++ b/packages/backend/src/helpers/global-variable.js @@ -0,0 +1,175 @@ +import createHttpClient from './http-client/index.js'; +import EarlyExitError from '../errors/early-exit.js'; +import AlreadyProcessedError from '../errors/already-processed.js'; +import Datastore from '../models/datastore.js'; + +const globalVariable = async (options) => { + const { + connection, + app, + flow, + step, + execution, + request, + testRun = false, + } = options; + + const isTrigger = step?.isTrigger; + const lastInternalId = testRun ? undefined : await flow?.lastInternalId(); + const nextStep = await step?.getNextStep(); + + const $ = { + auth: { + set: async (args) => { + if (connection) { + await connection.$query().patchAndFetch({ + formattedData: { + ...connection.formattedData, + ...args, + }, + }); + + $.auth.data = connection.formattedData; + } + + return null; + }, + data: connection?.formattedData, + }, + app: app, + flow: { + id: flow?.id, + lastInternalId, + }, + step: { + id: step?.id, + appKey: step?.appKey, + parameters: step?.parameters || {}, + }, + nextStep: { + id: nextStep?.id, + appKey: nextStep?.appKey, + parameters: nextStep?.parameters || {}, + }, + execution: { + id: execution?.id, + testRun, + exit: () => { + throw new EarlyExitError(); + }, + }, + getLastExecutionStep: async () => + (await step?.getLastExecutionStep())?.toJSON(), + triggerOutput: { + data: [], + }, + actionOutput: { + data: { + raw: null, + }, + }, + pushTriggerItem: (triggerItem) => { + if ( + isAlreadyProcessed(triggerItem.meta.internalId) && + !$.execution.testRun + ) { + // early exit as we do not want to process duplicate items in actual executions + throw new AlreadyProcessedError(); + } + + $.triggerOutput.data.push(triggerItem); + + const isWebhookApp = app.key === 'webhook'; + + if ($.execution.testRun && !isWebhookApp) { + // early exit after receiving one item as it is enough for test execution + throw new EarlyExitError(); + } + }, + setActionItem: (actionItem) => { + $.actionOutput.data = actionItem; + }, + datastore: { + get: async ({ key }) => { + const datastore = await Datastore.query().findOne({ + key, + scope: 'flow', + scope_id: $.flow.id, + }); + + return { + key: key, + value: datastore?.value ?? null, + [key]: datastore?.value ?? null, + }; + }, + set: async ({ key, value }) => { + let datastore = await Datastore.query() + .where({ key, scope: 'flow', scope_id: $.flow.id }) + .first(); + + if (datastore) { + await datastore.$query().patchAndFetch({ value: value }); + } else { + datastore = await Datastore.query().insert({ + key, + value, + scope: 'flow', + scopeId: $.flow.id, + }); + } + + return { + key: datastore.key, + value: datastore.value, + [datastore.key]: datastore.value, + }; + }, + }, + }; + + if (request) { + $.request = request; + } + + if (app) { + $.http = createHttpClient({ + $, + baseURL: app.apiBaseUrl, + beforeRequest: app.beforeRequest, + }); + } + + if (step) { + $.webhookUrl = await step.getWebhookUrl(); + } + + if (isTrigger) { + const triggerCommand = await step.getTriggerCommand(); + + if (triggerCommand.type === 'webhook') { + $.flow.setRemoteWebhookId = async (remoteWebhookId) => { + await flow.$query().patchAndFetch({ + remoteWebhookId, + }); + + $.flow.remoteWebhookId = remoteWebhookId; + }; + + $.flow.remoteWebhookId = flow.remoteWebhookId; + } + } + + const lastInternalIds = + testRun || (flow && step?.isAction) + ? [] + : await flow?.lastInternalIds(2000); + + const isAlreadyProcessed = (internalId) => { + return lastInternalIds?.includes(internalId); + }; + + return $; +}; + +export default globalVariable; diff --git a/packages/backend/src/helpers/http-client/index.js b/packages/backend/src/helpers/http-client/index.js new file mode 100644 index 0000000..7263603 --- /dev/null +++ b/packages/backend/src/helpers/http-client/index.js @@ -0,0 +1,43 @@ +import HttpError from '../../errors/http.js'; +import { createInstance } from '../axios-with-proxy.js'; + +export default function createHttpClient({ $, baseURL, beforeRequest = [] }) { + async function interceptResponseError(error) { + const { config, response } = error; + // Do not destructure `status` from `error.response` because it might not exist + const status = response?.status; + + if ( + // TODO: provide a `shouldRefreshToken` function in the app + (status === 401 || status === 403) && + $.app.auth && + $.app.auth.refreshToken && + !$.app.auth.isRefreshTokenRequested + ) { + $.app.auth.isRefreshTokenRequested = true; + await $.app.auth.refreshToken($); + + // retry the previous request before the expired token error + const newResponse = await instance.request(config); + $.app.auth.isRefreshTokenRequested = false; + + return newResponse; + } + + throw new HttpError(error); + }; + + const instance = createInstance( + { + baseURL, + }, + { + requestInterceptor: beforeRequest.map((originalBeforeRequest) => { + return async (requestConfig) => await originalBeforeRequest($, requestConfig); + }), + responseErrorInterceptor: interceptResponseError, + } + ) + + return instance; +} diff --git a/packages/backend/src/helpers/import-flow.js b/packages/backend/src/helpers/import-flow.js new file mode 100644 index 0000000..fe60957 --- /dev/null +++ b/packages/backend/src/helpers/import-flow.js @@ -0,0 +1,75 @@ +import Crypto from 'crypto'; +import Step from '../models/step.js'; +import { renderObjectionError } from './renderer.js'; + +const importFlow = async (user, flowData, response) => { + const steps = flowData.steps || []; + + // Validation: the first step must be a trigger + if (!steps.length || steps[0].type !== 'trigger') { + return renderObjectionError(response, { + statusCode: 422, + type: 'ValidationError', + data: { + steps: [{ message: 'The first step must be a trigger!' }], + }, + }); + } + + const newFlowId = Crypto.randomUUID(); + + const newFlow = await user.$relatedQuery('flows').insertAndFetch({ + id: newFlowId, + name: flowData.name, + active: false, + }); + + const stepIdMap = {}; + + // Generate new step IDs and insert steps without parameters + for (const step of steps) { + const newStepId = Crypto.randomUUID(); + stepIdMap[step.id] = newStepId; + + await Step.query().insert({ + id: newStepId, + flowId: newFlowId, + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: {}, + position: step.position, + webhookPath: step.webhookPath?.replace(flowData.id, newFlowId), + }); + } + + // Update steps with correct parameters + for (const step of steps) { + const newStepId = stepIdMap[step.id]; + + await Step.query().patchAndFetchById(newStepId, { + parameters: updateParameters(step.parameters, stepIdMap), + }); + } + + return await newFlow.$query().withGraphFetched('steps'); +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default importFlow; diff --git a/packages/backend/src/helpers/inject-bull-board-handler.js b/packages/backend/src/helpers/inject-bull-board-handler.js new file mode 100644 index 0000000..17266c0 --- /dev/null +++ b/packages/backend/src/helpers/inject-bull-board-handler.js @@ -0,0 +1,27 @@ +import basicAuth from 'express-basic-auth'; +import appConfig from '../config/app.js'; + +const injectBullBoardHandler = async (app, serverAdapter) => { + if ( + !appConfig.enableBullMQDashboard || + !appConfig.bullMQDashboardUsername || + !appConfig.bullMQDashboardPassword + ) + return; + + const queueDashboardBasePath = '/admin/queues'; + serverAdapter.setBasePath(queueDashboardBasePath); + + app.use( + queueDashboardBasePath, + basicAuth({ + users: { + [appConfig.bullMQDashboardUsername]: appConfig.bullMQDashboardPassword, + }, + challenge: true, + }), + serverAdapter.getRouter() + ); +}; + +export default injectBullBoardHandler; diff --git a/packages/backend/src/helpers/license.ee.js b/packages/backend/src/helpers/license.ee.js new file mode 100644 index 0000000..d3ae30c --- /dev/null +++ b/packages/backend/src/helpers/license.ee.js @@ -0,0 +1,37 @@ +import memoryCache from 'memory-cache'; +import appConfig from '../config/app.js'; +import axios from './axios-with-proxy.js'; + +const CACHE_DURATION = 1000 * 60 * 60 * 24; // 24 hours in milliseconds + +const hasValidLicense = async () => { + const license = await getLicense(); + + return license ? true : false; +}; + +const getLicense = async () => { + const licenseKey = appConfig.licenseKey; + + if (!licenseKey) { + return false; + } + + const url = 'https://license.automatisch.io/api/v1/licenses/verify'; + const cachedResponse = memoryCache.get(url); + + if (cachedResponse) { + return cachedResponse; + } else { + try { + const { data } = await axios.post(url, { licenseKey }); + memoryCache.put(url, data, CACHE_DURATION); + + return data; + } catch (error) { + return false; + } + } +}; + +export { getLicense, hasValidLicense }; diff --git a/packages/backend/src/helpers/logger.js b/packages/backend/src/helpers/logger.js new file mode 100644 index 0000000..d202869 --- /dev/null +++ b/packages/backend/src/helpers/logger.js @@ -0,0 +1,46 @@ +import * as winston from 'winston'; +import appConfig from '../config/app.js'; + +const levels = { + error: 0, + warn: 1, + http: 2, + info: 3, + debug: 4, +}; + +const colors = { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'white', +}; + +winston.addColors(colors); + +const format = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), + winston.format.colorize({ all: true }), + winston.format.printf( + (info) => `${info.timestamp} [${info.level}]: ${info.message}` + ) +); + +const transports = [ + new winston.transports.Console(), + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + }), + new winston.transports.File({ filename: 'logs/server.log' }), +]; + +export const logger = winston.createLogger({ + level: appConfig.logLevel, + levels, + format, + transports, +}); + +export default logger; diff --git a/packages/backend/src/helpers/mailer.ee.js b/packages/backend/src/helpers/mailer.ee.js new file mode 100644 index 0000000..2a57488 --- /dev/null +++ b/packages/backend/src/helpers/mailer.ee.js @@ -0,0 +1,14 @@ +import nodemailer from 'nodemailer'; +import appConfig from '../config/app.js'; + +const mailer = nodemailer.createTransport({ + host: appConfig.smtpHost, + port: appConfig.smtpPort, + secure: appConfig.smtpSecure, + auth: { + user: appConfig.smtpUser, + pass: appConfig.smtpPassword, + }, +}); + +export default mailer; diff --git a/packages/backend/src/helpers/morgan.js b/packages/backend/src/helpers/morgan.js new file mode 100644 index 0000000..12484f2 --- /dev/null +++ b/packages/backend/src/helpers/morgan.js @@ -0,0 +1,14 @@ +import morgan from 'morgan'; +import logger from './logger.js'; + +const stream = { + write: (message) => + logger.http(message.substring(0, message.lastIndexOf('\n'))), +}; + +const morganMiddleware = morgan( + ':method :url :status :res[content-length] - :response-time ms', + { stream } +); + +export default morganMiddleware; diff --git a/packages/backend/src/helpers/pagination-rest.js b/packages/backend/src/helpers/pagination-rest.js new file mode 100644 index 0000000..89239d8 --- /dev/null +++ b/packages/backend/src/helpers/pagination-rest.js @@ -0,0 +1,25 @@ +const paginateRest = async (query, page) => { + const pageSize = 10; + + page = parseInt(page, 10); + + if (isNaN(page) || page < 1) { + page = 1; + } + + const [records, count] = await Promise.all([ + query.limit(pageSize).offset((page - 1) * pageSize), + query.resultSize(), + ]); + + return { + pageInfo: { + currentPage: page, + totalPages: Math.ceil(count / pageSize), + }, + totalCount: count, + records, + }; +}; + +export default paginateRest; diff --git a/packages/backend/src/helpers/pagination.js b/packages/backend/src/helpers/pagination.js new file mode 100644 index 0000000..419df03 --- /dev/null +++ b/packages/backend/src/helpers/pagination.js @@ -0,0 +1,23 @@ +const paginate = async (query, limit, offset) => { + if (limit < 1 || limit > 100) { + throw new Error('Limit must be between 1 and 100'); + } + + const [records, count] = await Promise.all([ + query.limit(limit).offset(offset), + query.resultSize(), + ]); + + return { + pageInfo: { + currentPage: Math.ceil(offset / limit + 1), + totalPages: Math.ceil(count / limit), + }, + totalCount: count, + edges: records.map((record) => ({ + node: record, + })), + }; +}; + +export default paginate; diff --git a/packages/backend/src/helpers/parse-header-link.js b/packages/backend/src/helpers/parse-header-link.js new file mode 100644 index 0000000..face70f --- /dev/null +++ b/packages/backend/src/helpers/parse-header-link.js @@ -0,0 +1,29 @@ +export default function parseLinkHeader(link) { + const parsed = {}; + + if (!link) return parsed; + + const items = link.split(','); + + for (const item of items) { + const [rawUriReference, ...rawLinkParameters] = item.split(';'); + const trimmedUriReference = rawUriReference.trim(); + + const reference = trimmedUriReference.slice(1, -1); + const parameters = {}; + + for (const rawParameter of rawLinkParameters) { + const trimmedRawParameter = rawParameter.trim(); + const [key, value] = trimmedRawParameter.split('='); + + parameters[key.trim()] = value.slice(1, -1); + } + + parsed[parameters.rel] = { + uri: reference, + parameters, + }; + } + + return parsed; +} diff --git a/packages/backend/src/helpers/passport.js b/packages/backend/src/helpers/passport.js new file mode 100644 index 0000000..9c7ebaa --- /dev/null +++ b/packages/backend/src/helpers/passport.js @@ -0,0 +1,129 @@ +import { URL } from 'node:url'; +import { MultiSamlStrategy } from '@node-saml/passport-saml'; +import passport from 'passport'; + +import appConfig from '../config/app.js'; +import createAuthTokenByUserId from './create-auth-token-by-user-id.js'; +import SamlAuthProvider from '../models/saml-auth-provider.ee.js'; +import AccessToken from '../models/access-token.js'; +import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js'; + +const asyncNoop = async () => { }; + +export default function configurePassport(app) { + app.use( + passport.initialize({ + userProperty: 'currentUser', + }) + ); + + passport.use( + new MultiSamlStrategy( + { + passReqToCallback: true, + getSamlOptions: async function (request, done) { + // This is a workaround to avoid session logout which passport-saml enforces + request.logout = asyncNoop; + request.logOut = asyncNoop; + + const { issuer } = request.params; + const notFoundIssuer = new Error('Issuer cannot be found!'); + + if (!issuer) return done(notFoundIssuer); + + const authProvider = await SamlAuthProvider.query().findOne({ + issuer: request.params.issuer, + }); + + if (!authProvider) { + return done(notFoundIssuer); + } + + return done(null, authProvider.config); + }, + }, + async function signonVerify(request, user, done) { + const { issuer } = request.params; + const notFoundIssuer = new Error('Issuer cannot be found!'); + + if (!issuer) return done(notFoundIssuer); + + const authProvider = await SamlAuthProvider.query().findOne({ + issuer: request.params.issuer, + }); + + if (!authProvider) { + return done(notFoundIssuer); + } + + const foundUserWithIdentity = await findOrCreateUserBySamlIdentity( + user, + authProvider + ); + + request.samlSessionId = user.sessionIndex; + + return done(null, foundUserWithIdentity); + }, + async function logoutVerify(request, user, done) { + const { issuer } = request.params; + const notFoundIssuer = new Error('Issuer cannot be found!'); + + if (!issuer) return done(notFoundIssuer); + + const authProvider = await SamlAuthProvider.query().findOne({ + issuer: request.params.issuer, + }); + + if (!authProvider) { + return done(notFoundIssuer); + } + + const foundUserWithIdentity = await findOrCreateUserBySamlIdentity( + user, + authProvider + ); + + const accessToken = await AccessToken.query().findOne({ + revoked_at: null, + saml_session_id: user.sessionIndex, + }).throwIfNotFound(); + + await accessToken.revoke(); + + return done(null, foundUserWithIdentity); + } + ) + ); + + app.get( + '/login/saml/:issuer', + passport.authenticate('saml', { + session: false, + successRedirect: '/', + }) + ); + + app.post( + '/login/saml/:issuer/callback', + passport.authenticate('saml', { + session: false, + }), + async (request, response) => { + const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId); + + const redirectUrl = new URL( + `/login/callback?token=${token}`, + appConfig.webAppUrl + ).toString(); + response.redirect(redirectUrl); + } + ); + + app.post( + '/logout/saml/:issuer', + passport.authenticate('saml', { + session: false, + }), + ); +} diff --git a/packages/backend/src/helpers/permission-catalog.ee.js b/packages/backend/src/helpers/permission-catalog.ee.js new file mode 100644 index 0000000..1f527d9 --- /dev/null +++ b/packages/backend/src/helpers/permission-catalog.ee.js @@ -0,0 +1,72 @@ +const Connection = { + label: 'Connection', + key: 'Connection', +}; + +const Flow = { + label: 'Flow', + key: 'Flow', +}; + +const Execution = { + label: 'Execution', + key: 'Execution', +}; + +const permissionCatalog = { + conditions: [ + { + key: 'isCreator', + label: 'Is creator' + } + ], + actions: [ + { + label: 'Create', + key: 'create', + subjects: [ + Connection.key, + Flow.key, + ] + }, + { + label: 'Read', + key: 'read', + subjects: [ + Connection.key, + Execution.key, + Flow.key, + ] + }, + { + label: 'Update', + key: 'update', + subjects: [ + Connection.key, + Flow.key, + ] + }, + { + label: 'Delete', + key: 'delete', + subjects: [ + Connection.key, + Flow.key, + ] + }, + { + label: 'Publish', + key: 'publish', + subjects: [ + Flow.key, + ] + } + ], + subjects: [ + Connection, + Flow, + Execution + ] +}; + +export default permissionCatalog; diff --git a/packages/backend/src/helpers/remove-job-configuration.js b/packages/backend/src/helpers/remove-job-configuration.js new file mode 100644 index 0000000..e6e0144 --- /dev/null +++ b/packages/backend/src/helpers/remove-job-configuration.js @@ -0,0 +1,10 @@ +export const REMOVE_AFTER_30_DAYS_OR_150_JOBS = { + age: 30 * 24 * 3600, + count: 150, +}; + +export const REMOVE_AFTER_7_DAYS_OR_50_JOBS = { + age: 7 * 24 * 3600, + count: 50, +}; + diff --git a/packages/backend/src/helpers/renderer.js b/packages/backend/src/helpers/renderer.js new file mode 100644 index 0000000..e8d4785 --- /dev/null +++ b/packages/backend/src/helpers/renderer.js @@ -0,0 +1,94 @@ +import serializers from '../serializers/index.js'; + +const isPaginated = (object) => + object?.pageInfo && + object?.totalCount !== undefined && + Array.isArray(object?.records); + +const isArray = (object) => + Array.isArray(object) || Array.isArray(object?.records); + +const totalCount = (object) => + isPaginated(object) ? object.totalCount : isArray(object) ? object.length : 1; + +const renderObject = (response, object, options) => { + let data = isPaginated(object) ? object.records : object; + + const type = isPaginated(object) + ? object.records[0]?.constructor?.name || 'Object' + : Array.isArray(object) + ? object?.[0]?.constructor?.name || 'Object' + : object.constructor.name; + + const serializer = options?.serializer + ? serializers[options.serializer] + : serializers[type]; + + if (serializer) { + data = Array.isArray(data) + ? data.map((item) => serializer(item)) + : serializer(data); + } + + const computedPayload = { + data, + meta: { + type, + count: totalCount(object), + isArray: isArray(object), + currentPage: isPaginated(object) ? object.pageInfo.currentPage : null, + totalPages: isPaginated(object) ? object.pageInfo.totalPages : null, + }, + }; + + const status = options?.status || 200; + + return response.status(status).json(computedPayload); +}; + +const renderError = (response, errors, status, type) => { + const errorStatus = status || 422; + const errorType = type || 'ValidationError'; + + const payload = { + errors: errors.reduce((acc, error) => { + const key = Object.keys(error)[0]; + acc[key] = error[key]; + return acc; + }, {}), + meta: { + type: errorType, + }, + }; + + return response.status(errorStatus).send(payload); +}; + +const renderUniqueViolationError = (response, error) => { + const errors = error.columns.map((column) => ({ + [column]: [`'${column}' must be unique.`], + })); + + return renderError(response, errors, 422, 'UniqueViolationError'); +}; + +const renderObjectionError = (response, error, status) => { + const { statusCode, type, data = {} } = error; + + const computedStatusCode = status || statusCode; + + const computedErrors = Object.entries(data).map( + ([fieldName, fieldErrors]) => ({ + [fieldName]: fieldErrors.map(({ message }) => message), + }) + ); + + return renderError(response, computedErrors, computedStatusCode, type); +}; + +export { + renderObject, + renderError, + renderObjectionError, + renderUniqueViolationError, +}; diff --git a/packages/backend/src/helpers/sentry.ee.js b/packages/backend/src/helpers/sentry.ee.js new file mode 100644 index 0000000..141c523 --- /dev/null +++ b/packages/backend/src/helpers/sentry.ee.js @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/node'; +import * as Tracing from '@sentry/tracing'; + +import appConfig from '../config/app.js'; + +const isSentryEnabled = () => { + if (appConfig.isDev || appConfig.isTest) return false; + return !!appConfig.sentryDsn; +}; + +export function init(app) { + if (!isSentryEnabled()) return; + + return Sentry.init({ + enabled: !!appConfig.sentryDsn, + dsn: appConfig.sentryDsn, + integrations: [ + app && new Sentry.Integrations.Http({ tracing: true }), + app && new Tracing.Integrations.Express({ app }), + ].filter(Boolean), + tracesSampleRate: 1.0, + }); +} + +export function attachRequestHandler(app) { + if (!isSentryEnabled()) return; + + app.use(Sentry.Handlers.requestHandler()); +} + +export function attachTracingHandler(app) { + if (!isSentryEnabled()) return; + + app.use(Sentry.Handlers.tracingHandler()); +} + +export function attachErrorHandler(app) { + if (!isSentryEnabled()) return; + + app.use( + Sentry.Handlers.errorHandler({ + shouldHandleError() { + // TODO: narrow down the captured errors in time as we receive samples + return true; + }, + }) + ); +} + +export function captureException(exception, captureContext) { + if (!isSentryEnabled()) return; + + return Sentry.captureException(exception, captureContext); +} diff --git a/packages/backend/src/helpers/telemetry/index.js b/packages/backend/src/helpers/telemetry/index.js new file mode 100644 index 0000000..7791aec --- /dev/null +++ b/packages/backend/src/helpers/telemetry/index.js @@ -0,0 +1,149 @@ +import Analytics from '@rudderstack/rudder-sdk-node'; +import organizationId from './organization-id.js'; +import instanceId from './instance-id.js'; +import appConfig from '../../config/app.js'; +import os from 'os'; + +const WRITE_KEY = '284Py4VgK2MsNYV7xlKzyrALx0v'; +const DATA_PLANE_URL = 'https://telemetry.automatisch.io/v1/batch'; +const CPUS = os.cpus(); +const SIX_HOURS_IN_MILLISECONDS = 21600000; + +class Telemetry { + constructor() { + this.client = new Analytics(WRITE_KEY, DATA_PLANE_URL); + this.organizationId = organizationId(); + this.instanceId = instanceId(); + } + + setServiceType(type) { + this.serviceType = type; + } + + track(name, properties) { + if (!appConfig.telemetryEnabled) { + return; + } + + properties = { + ...properties, + appEnv: appConfig.appEnv, + instanceId: this.instanceId, + }; + + this.client.track({ + userId: this.organizationId, + event: name, + properties, + }); + } + + stepCreated(step) { + this.track('stepCreated', { + stepId: step.id, + flowId: step.flowId, + createdAt: step.createdAt, + updatedAt: step.updatedAt, + }); + } + + stepUpdated(step) { + this.track('stepUpdated', { + stepId: step.id, + flowId: step.flowId, + key: step.key, + appKey: step.appKey, + type: step.type, + position: step.position, + status: step.status, + createdAt: step.createdAt, + updatedAt: step.updatedAt, + }); + } + + flowCreated(flow) { + this.track('flowCreated', { + flowId: flow.id, + name: flow.name, + active: flow.active, + createdAt: flow.createdAt, + updatedAt: flow.updatedAt, + }); + } + + flowUpdated(flow) { + this.track('flowUpdated', { + flowId: flow.id, + name: flow.name, + active: flow.active, + createdAt: flow.createdAt, + updatedAt: flow.updatedAt, + }); + } + + executionCreated(execution) { + this.track('executionCreated', { + executionId: execution.id, + flowId: execution.flowId, + testRun: execution.testRun, + createdAt: execution.createdAt, + updatedAt: execution.updatedAt, + }); + } + + executionStepCreated(executionStep) { + this.track('executionStepCreated', { + executionStepId: executionStep.id, + executionId: executionStep.executionId, + stepId: executionStep.stepId, + status: executionStep.status, + createdAt: executionStep.createdAt, + updatedAt: executionStep.updatedAt, + }); + } + + connectionCreated(connection) { + this.track('connectionCreated', { + connectionId: connection.id, + key: connection.key, + verified: connection.verified, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + }); + } + + connectionUpdated(connection) { + this.track('connectionUpdated', { + connectionId: connection.id, + key: connection.key, + verified: connection.verified, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + }); + } + + diagnosticInfo() { + this.track('diagnosticInfo', { + automatischVersion: appConfig.version, + serveWebAppSeparately: appConfig.serveWebAppSeparately, + serviceType: this.serviceType, + operatingSystem: { + type: os.type(), + version: os.version(), + }, + memory: os.totalmem() / (1024 * 1024), // To get as megabytes + cpus: { + count: CPUS.length, + model: CPUS[0].model, + speed: CPUS[0].speed, + }, + }); + + setTimeout(() => this.diagnosticInfo(), SIX_HOURS_IN_MILLISECONDS); + } +} + +const telemetry = new Telemetry(); +telemetry.diagnosticInfo(); + +export default telemetry; diff --git a/packages/backend/src/helpers/telemetry/instance-id.js b/packages/backend/src/helpers/telemetry/instance-id.js new file mode 100644 index 0000000..ce0b302 --- /dev/null +++ b/packages/backend/src/helpers/telemetry/instance-id.js @@ -0,0 +1,7 @@ +import Crypto from 'crypto'; + +const instanceId = () => { + return Crypto.randomUUID(); +}; + +export default instanceId; diff --git a/packages/backend/src/helpers/telemetry/organization-id.js b/packages/backend/src/helpers/telemetry/organization-id.js new file mode 100644 index 0000000..14c7f41 --- /dev/null +++ b/packages/backend/src/helpers/telemetry/organization-id.js @@ -0,0 +1,13 @@ +import CryptoJS from 'crypto-js'; +import appConfig from '../../config/app.js'; + +const organizationId = () => { + const key = appConfig.encryptionKey; + const hash = CryptoJS.SHA3(key, { outputLength: 256 }).toString( + CryptoJS.enc.Hex + ); + + return hash; +}; + +export default organizationId; diff --git a/packages/backend/src/helpers/user-ability.js b/packages/backend/src/helpers/user-ability.js new file mode 100644 index 0000000..74f4620 --- /dev/null +++ b/packages/backend/src/helpers/user-ability.js @@ -0,0 +1,23 @@ +import { + PureAbility, + fieldPatternMatcher, + mongoQueryMatcher, +} from '@casl/ability'; + +// Must be kept in sync with `packages/web/src/helpers/userAbility.ts`! +export default function userAbility(user) { + const permissions = user?.permissions; + const role = user?.role; + + // We're not using mongo, but our fields, conditions match + const options = { + conditionsMatcher: mongoQueryMatcher, + fieldMatcher: fieldPatternMatcher, + }; + + if (!role || !permissions) { + return new PureAbility([], options); + } + + return new PureAbility(permissions, options); +} diff --git a/packages/backend/src/helpers/user-ability.test.js b/packages/backend/src/helpers/user-ability.test.js new file mode 100644 index 0000000..906a5fb --- /dev/null +++ b/packages/backend/src/helpers/user-ability.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import userAbility from './user-ability.js'; + +describe('userAbility', () => { + it('should return PureAbility instantiated with user permissions', () => { + const user = { + permissions: [ + { + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }, + ], + role: { + name: 'User', + }, + }; + + const ability = userAbility(user); + + expect(ability.rules).toStrictEqual(user.permissions); + }); + + it('should return permission-less PureAbility for user with no role', () => { + const user = { + permissions: [ + { + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }, + ], + role: null, + }; + const ability = userAbility(user); + + expect(ability.rules).toStrictEqual([]); + }); + + it('should return permission-less PureAbility for user with no permissions', () => { + const user = { permissions: null, role: { name: 'User' } }; + const ability = userAbility(user); + + expect(ability.rules).toStrictEqual([]); + }); +}); diff --git a/packages/backend/src/helpers/web-ui-handler.js b/packages/backend/src/helpers/web-ui-handler.js new file mode 100644 index 0000000..a20c66b --- /dev/null +++ b/packages/backend/src/helpers/web-ui-handler.js @@ -0,0 +1,25 @@ +import express from 'express'; +import path, { join } from 'path'; +import appConfig from '../config/app.js'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const webUIHandler = async (app) => { + if (appConfig.serveWebAppSeparately) return; + + const webAppPath = join(__dirname, '../../../web/'); + const webBuildPath = join(webAppPath, 'build'); + const indexHtml = join(webAppPath, 'build', 'index.html'); + + app.use(express.static(webBuildPath)); + + app.get('*', (_req, res) => { + res.set('Content-Security-Policy', 'frame-ancestors \'none\';'); + res.set('X-Frame-Options', 'DENY'); + + res.sendFile(indexHtml); + }); +}; + +export default webUIHandler; diff --git a/packages/backend/src/helpers/webhook-handler-sync.js b/packages/backend/src/helpers/webhook-handler-sync.js new file mode 100644 index 0000000..8b20990 --- /dev/null +++ b/packages/backend/src/helpers/webhook-handler-sync.js @@ -0,0 +1,109 @@ +import isEmpty from 'lodash/isEmpty.js'; + +import Flow from '../models/flow.js'; +import { processTrigger } from '../services/trigger.js'; +import { processAction } from '../services/action.js'; +import globalVariable from './global-variable.js'; +import QuotaExceededError from '../errors/quote-exceeded.js'; + +export default async (flowId, request, response) => { + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const user = await flow.$relatedQuery('user'); + + const testRun = !flow.active; + const quotaExceeded = !testRun && !(await user.isAllowedToRunFlows()); + + if (quotaExceeded) { + throw new QuotaExceededError(); + } + + const [triggerStep, ...actionSteps] = await flow + .$relatedQuery('steps') + .withGraphFetched('connection') + .orderBy('position', 'asc'); + const app = await triggerStep.getApp(); + const isWebhookApp = app.key === 'webhook'; + + if (testRun && !isWebhookApp) { + return response.status(404); + } + + const connection = await triggerStep.$relatedQuery('connection'); + + const $ = await globalVariable({ + flow, + connection, + app, + step: triggerStep, + testRun, + request, + }); + + const triggerCommand = await triggerStep.getTriggerCommand(); + await triggerCommand.run($); + + const reversedTriggerItems = $.triggerOutput.data.reverse(); + + // This is the case when we filter out the incoming data + // in the run method of the webhook trigger. + // In this case, we don't want to process anything. + if (isEmpty(reversedTriggerItems)) { + return response.status(204); + } + + // set default status, but do not send it yet! + response.status(204); + + for (const triggerItem of reversedTriggerItems) { + const { executionId } = await processTrigger({ + flowId, + stepId: triggerStep.id, + triggerItem, + testRun, + }); + + if (testRun) { + response.status(204).end(); + + // in case of testing, we do not process the whole process. + continue; + } + + for (const actionStep of actionSteps) { + const { executionStep: actionExecutionStep } = await processAction({ + flowId: flow.id, + stepId: actionStep.id, + executionId, + }); + + if (actionStep.appKey === 'filter' && !actionExecutionStep.dataOut) { + response.status(422).end(); + + break; + } + + if ( + (actionStep.key === 'respondWith' || + actionStep.key === 'respondWithVoiceXml') && + !response.headersSent + ) { + const { headers, statusCode, body } = actionExecutionStep.dataOut; + + // we set the custom response headers + if (headers) { + for (const [key, value] of Object.entries(headers)) { + if (key) { + response.set(key, value); + } + } + } + + // we send the response only if it's not sent yet. This allows us to early respond from the flow. + response.status(statusCode); + response.send(body); + } + } + } + + return response; +}; diff --git a/packages/backend/src/helpers/webhook-handler.js b/packages/backend/src/helpers/webhook-handler.js new file mode 100644 index 0000000..fc39b12 --- /dev/null +++ b/packages/backend/src/helpers/webhook-handler.js @@ -0,0 +1,84 @@ +import isEmpty from 'lodash/isEmpty.js'; + +import Flow from '../models/flow.js'; +import { processTrigger } from '../services/trigger.js'; +import triggerQueue from '../queues/trigger.js'; +import globalVariable from './global-variable.js'; +import QuotaExceededError from '../errors/quote-exceeded.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from './remove-job-configuration.js'; + +export default async (flowId, request, response) => { + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const user = await flow.$relatedQuery('user'); + + const testRun = !flow.active; + const quotaExceeded = !testRun && !(await user.isAllowedToRunFlows()); + + if (quotaExceeded) { + throw new QuotaExceededError(); + } + + const triggerStep = await flow.getTriggerStep(); + const app = await triggerStep.getApp(); + const isWebhookApp = app.key === 'webhook'; + + if (testRun && !isWebhookApp) { + return response.status(404); + } + + const connection = await triggerStep.$relatedQuery('connection'); + + const $ = await globalVariable({ + flow, + connection, + app, + step: triggerStep, + testRun, + request, + }); + + const triggerCommand = await triggerStep.getTriggerCommand(); + await triggerCommand.run($); + + const reversedTriggerItems = $.triggerOutput.data.reverse(); + + // This is the case when we filter out the incoming data + // in the run method of the webhook trigger. + // In this case, we don't want to process anything. + if (isEmpty(reversedTriggerItems)) { + return response.status(204); + } + + for (const triggerItem of reversedTriggerItems) { + if (testRun) { + await processTrigger({ + flowId, + stepId: triggerStep.id, + triggerItem, + testRun, + }); + + continue; + } + + const jobName = `${triggerStep.id}-${triggerItem.meta.internalId}`; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + const jobPayload = { + flowId, + stepId: triggerStep.id, + triggerItem, + }; + + await triggerQueue.add(jobName, jobPayload, jobOptions); + } + + return response.status(204); +}; diff --git a/packages/backend/src/jobs/delete-user.ee.js b/packages/backend/src/jobs/delete-user.ee.js new file mode 100644 index 0000000..a6d58f3 --- /dev/null +++ b/packages/backend/src/jobs/delete-user.ee.js @@ -0,0 +1,37 @@ +import appConfig from '../config/app.js'; +import User from '../models/user.js'; +import ExecutionStep from '../models/execution-step.js'; + +export const deleteUserJob = async (job) => { + const { id } = job.data; + + const user = await User.query() + .withSoftDeleted() + .findById(id) + .throwIfNotFound(); + + const executionIds = ( + await user + .$relatedQuery('executions') + .withSoftDeleted() + .select('executions.id') + ).map((execution) => execution.id); + + await ExecutionStep.query() + .withSoftDeleted() + .whereIn('execution_id', executionIds) + .hardDelete(); + await user.$relatedQuery('executions').withSoftDeleted().hardDelete(); + await user.$relatedQuery('steps').withSoftDeleted().hardDelete(); + await user.$relatedQuery('flows').withSoftDeleted().hardDelete(); + await user.$relatedQuery('connections').withSoftDeleted().hardDelete(); + await user.$relatedQuery('identities').withSoftDeleted().hardDelete(); + + if (appConfig.isCloud) { + await user.$relatedQuery('subscriptions').withSoftDeleted().hardDelete(); + await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); + } + + await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete(); + await user.$query().withSoftDeleted().hardDelete(); +}; diff --git a/packages/backend/src/jobs/execute-action.js b/packages/backend/src/jobs/execute-action.js new file mode 100644 index 0000000..2d283c1 --- /dev/null +++ b/packages/backend/src/jobs/execute-action.js @@ -0,0 +1,46 @@ +import Step from '../models/step.js'; +import actionQueue from '../queues/action.js'; +import { processAction } from '../services/action.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; +import delayAsMilliseconds from '../helpers/delay-as-milliseconds.js'; + +const DEFAULT_DELAY_DURATION = 0; + +export const executeActionJob = async (job) => { + const { stepId, flowId, executionId, computedParameters, executionStep } = + await processAction(job.data); + + if (executionStep.isFailed) return; + + const step = await Step.query().findById(stepId).throwIfNotFound(); + const nextStep = await step.getNextStep(); + + if (!nextStep) return; + + const jobName = `${executionId}-${nextStep.id}`; + + const jobPayload = { + flowId, + executionId, + stepId: nextStep.id, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + delay: DEFAULT_DELAY_DURATION, + }; + + if (step.appKey === 'delay') { + jobOptions.delay = delayAsMilliseconds(step.key, computedParameters); + } + + if (step.appKey === 'filter' && !executionStep.dataOut) { + return; + } + + await actionQueue.add(jobName, jobPayload, jobOptions); +}; diff --git a/packages/backend/src/jobs/execute-flow.js b/packages/backend/src/jobs/execute-flow.js new file mode 100644 index 0000000..ac6e063 --- /dev/null +++ b/packages/backend/src/jobs/execute-flow.js @@ -0,0 +1,54 @@ +import triggerQueue from '../queues/trigger.js'; +import { processFlow } from '../services/flow.js'; +import Flow from '../models/flow.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +export const executeFlowJob = async (job) => { + const { flowId } = job.data; + + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const user = await flow.$relatedQuery('user'); + const allowedToRunFlows = await user.isAllowedToRunFlows(); + + if (!allowedToRunFlows) { + return; + } + + const triggerStep = await flow.getTriggerStep(); + + const { data, error } = await processFlow({ flowId }); + + const reversedData = data.reverse(); + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + for (const triggerItem of reversedData) { + const jobName = `${triggerStep.id}-${triggerItem.meta.internalId}`; + + const jobPayload = { + flowId, + stepId: triggerStep.id, + triggerItem, + }; + + await triggerQueue.add(jobName, jobPayload, jobOptions); + } + + if (error) { + const jobName = `${triggerStep.id}-error`; + + const jobPayload = { + flowId, + stepId: triggerStep.id, + error, + }; + + await triggerQueue.add(jobName, jobPayload, jobOptions); + } +}; diff --git a/packages/backend/src/jobs/execute-trigger.js b/packages/backend/src/jobs/execute-trigger.js new file mode 100644 index 0000000..b81d6ff --- /dev/null +++ b/packages/backend/src/jobs/execute-trigger.js @@ -0,0 +1,32 @@ +import actionQueue from '../queues/action.js'; +import Step from '../models/step.js'; +import { processTrigger } from '../services/trigger.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +export const executeTriggerJob = async (job) => { + const { flowId, executionId, stepId, executionStep } = await processTrigger( + job.data + ); + + if (executionStep.isFailed) return; + + const step = await Step.query().findById(stepId).throwIfNotFound(); + const nextStep = await step.getNextStep(); + const jobName = `${executionId}-${nextStep.id}`; + + const jobPayload = { + flowId, + executionId, + stepId: nextStep.id, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + await actionQueue.add(jobName, jobPayload, jobOptions); +}; diff --git a/packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js b/packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js new file mode 100644 index 0000000..b8d3361 --- /dev/null +++ b/packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js @@ -0,0 +1,15 @@ +import { DateTime } from 'luxon'; +import Subscription from '../models/subscription.ee.js'; + +export const removeCancelledSubscriptionsJob = async () => { + await Subscription.query() + .delete() + .where({ + status: 'deleted', + }) + .andWhere( + 'cancellation_effective_date', + '<=', + DateTime.now().startOf('day').toISODate() + ); +}; diff --git a/packages/backend/src/jobs/send-email.js b/packages/backend/src/jobs/send-email.js new file mode 100644 index 0000000..ed81849 --- /dev/null +++ b/packages/backend/src/jobs/send-email.js @@ -0,0 +1,31 @@ +import logger from '../helpers/logger.js'; +import mailer from '../helpers/mailer.ee.js'; +import compileEmail from '../helpers/compile-email.ee.js'; +import appConfig from '../config/app.js'; + +export const sendEmailJob = async (job) => { + const { email, subject, template, params } = job.data; + + if (isCloudSandbox() && !isAutomatischEmail(email)) { + logger.info( + 'Only Automatisch emails are allowed for non-production environments!' + ); + + return; + } + + await mailer.sendMail({ + to: email, + from: appConfig.fromEmail, + subject: subject, + html: compileEmail(template, params), + }); +}; + +const isCloudSandbox = () => { + return appConfig.isCloud && !appConfig.isProd; +}; + +const isAutomatischEmail = (email) => { + return email.endsWith('@automatisch.io'); +}; diff --git a/packages/backend/src/models/__snapshots__/access-token.test.js.snap b/packages/backend/src/models/__snapshots__/access-token.test.js.snap new file mode 100644 index 0000000..77f8ab9 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/access-token.test.js.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AccessToken model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "expiresIn": { + "type": "integer", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "revokedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "samlSessionId": { + "type": [ + "string", + "null", + ], + }, + "token": { + "minLength": 32, + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "token", + "expiresIn", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/app-config.test.js.snap b/packages/backend/src/models/__snapshots__/app-config.test.js.snap new file mode 100644 index 0000000..38ca203 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/app-config.test.js.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AppConfig model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "disabled": { + "default": false, + "type": "boolean", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "key": { + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "useOnlyPredefinedAuthClients": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "key", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/app.test.js.snap b/packages/backend/src/models/__snapshots__/app.test.js.snap new file mode 100644 index 0000000..624194a --- /dev/null +++ b/packages/backend/src/models/__snapshots__/app.test.js.snap @@ -0,0 +1,81 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App model > list should have list of applications keys 1`] = ` +[ + "airtable", + "anthropic", + "appwrite", + "azure-openai", + "brave-search", + "carbone", + "clickup", + "code", + "cryptography", + "datastore", + "deepl", + "delay", + "discord", + "disqus", + "dropbox", + "filter", + "flickr", + "flowers-software", + "formatter", + "freescout", + "ghost", + "github", + "gitlab", + "google-calendar", + "google-drive", + "google-forms", + "google-sheets", + "google-tasks", + "helix", + "http-request", + "hubspot", + "invoice-ninja", + "jotform", + "mailchimp", + "mailerlite", + "mattermost", + "miro", + "mistral-ai", + "notion", + "ntfy", + "odoo", + "openai", + "openrouter", + "perplexity", + "pipedrive", + "placetel", + "postgresql", + "pushover", + "reddit", + "removebg", + "rss", + "salesforce", + "scheduler", + "self-hosted-llm", + "signalwire", + "slack", + "smtp", + "spotify", + "strava", + "stripe", + "telegram-bot", + "todoist", + "together-ai", + "trello", + "twilio", + "twitter", + "typeform", + "virtualq", + "vtiger-crm", + "webhook", + "wordpress", + "xero", + "you-need-a-budget", + "youtube", + "zendesk", +] +`; diff --git a/packages/backend/src/models/__snapshots__/config.test.js.snap b/packages/backend/src/models/__snapshots__/config.test.js.snap new file mode 100644 index 0000000..cf1c527 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/config.test.js.snap @@ -0,0 +1,52 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Config model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "installationCompleted": { + "type": "boolean", + }, + "logoSvgData": { + "type": [ + "string", + "null", + ], + }, + "palettePrimaryDark": { + "type": [ + "string", + "null", + ], + }, + "palettePrimaryLight": { + "type": [ + "string", + "null", + ], + }, + "palettePrimaryMain": { + "type": [ + "string", + "null", + ], + }, + "title": { + "type": [ + "string", + "null", + ], + }, + "updatedAt": { + "type": "string", + }, + }, + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/connection.test.js.snap b/packages/backend/src/models/__snapshots__/connection.test.js.snap new file mode 100644 index 0000000..405133b --- /dev/null +++ b/packages/backend/src/models/__snapshots__/connection.test.js.snap @@ -0,0 +1,51 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Connection model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "data": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "draft": { + "type": "boolean", + }, + "formattedData": { + "type": "object", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "key": { + "maxLength": 255, + "minLength": 1, + "type": "string", + }, + "oauthClientId": { + "format": "uuid", + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + "verified": { + "default": false, + "type": "boolean", + }, + }, + "required": [ + "key", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/datastore.test.js.snap b/packages/backend/src/models/__snapshots__/datastore.test.js.snap new file mode 100644 index 0000000..92eb347 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/datastore.test.js.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Datastore model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "id": { + "format": "uuid", + "type": "string", + }, + "key": { + "minLength": 1, + "type": "string", + }, + "scope": { + "default": "flow", + "enum": [ + "flow", + ], + "type": "string", + }, + "scopeId": { + "format": "uuid", + "type": "string", + }, + "value": { + "type": "string", + }, + }, + "required": [ + "key", + "value", + "scopeId", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/execution-step.test.js.snap b/packages/backend/src/models/__snapshots__/execution-step.test.js.snap new file mode 100644 index 0000000..c075661 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/execution-step.test.js.snap @@ -0,0 +1,54 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExecutionStep model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "dataIn": { + "type": [ + "object", + "null", + ], + }, + "dataOut": { + "type": [ + "object", + "null", + ], + }, + "deletedAt": { + "type": "string", + }, + "errorDetails": { + "type": [ + "object", + "null", + ], + }, + "executionId": { + "format": "uuid", + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "status": { + "enum": [ + "success", + "failure", + ], + "type": "string", + }, + "stepId": { + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + }, + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/execution.test.js.snap b/packages/backend/src/models/__snapshots__/execution.test.js.snap new file mode 100644 index 0000000..ba4d99c --- /dev/null +++ b/packages/backend/src/models/__snapshots__/execution.test.js.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Execution model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "flowId": { + "format": "uuid", + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "internalId": { + "type": "string", + }, + "testRun": { + "default": false, + "type": "boolean", + }, + "updatedAt": { + "type": "string", + }, + }, + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/flow.test.js.snap b/packages/backend/src/models/__snapshots__/flow.test.js.snap new file mode 100644 index 0000000..e07cde7 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/flow.test.js.snap @@ -0,0 +1,49 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Flow model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "active": { + "type": "boolean", + }, + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "folderId": { + "format": "uuid", + "type": [ + "string", + "null", + ], + }, + "id": { + "format": "uuid", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "publishedAt": { + "type": "string", + }, + "remoteWebhookId": { + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/folder.test.js.snap b/packages/backend/src/models/__snapshots__/folder.test.js.snap new file mode 100644 index 0000000..ede97df --- /dev/null +++ b/packages/backend/src/models/__snapshots__/folder.test.js.snap @@ -0,0 +1,27 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Folder model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/identity.ee.test.js.snap b/packages/backend/src/models/__snapshots__/identity.ee.test.js.snap new file mode 100644 index 0000000..b64f46b --- /dev/null +++ b/packages/backend/src/models/__snapshots__/identity.ee.test.js.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Identity model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "id": { + "format": "uuid", + "type": "string", + }, + "providerId": { + "format": "uuid", + "type": "string", + }, + "providerType": { + "enum": [ + "saml", + ], + "type": "string", + }, + "remoteId": { + "minLength": 1, + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "providerId", + "remoteId", + "userId", + "providerType", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/oauth-client.test.js.snap b/packages/backend/src/models/__snapshots__/oauth-client.test.js.snap new file mode 100644 index 0000000..04b3811 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/oauth-client.test.js.snap @@ -0,0 +1,39 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`OAuthClient model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "active": { + "type": "boolean", + }, + "appKey": { + "type": "string", + }, + "authDefaults": { + "type": [ + "string", + "null", + ], + }, + "createdAt": { + "type": "string", + }, + "formattedAuthDefaults": { + "type": "object", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + }, + "required": [ + "name", + "appKey", + "formattedAuthDefaults", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/permission.test.js.snap b/packages/backend/src/models/__snapshots__/permission.test.js.snap new file mode 100644 index 0000000..4b861e3 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/permission.test.js.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Permission model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "action": { + "minLength": 1, + "type": "string", + }, + "conditions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "createdAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "roleId": { + "format": "uuid", + "type": "string", + }, + "subject": { + "minLength": 1, + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + }, + "required": [ + "roleId", + "action", + "subject", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/role-mapping.ee.test.js.snap b/packages/backend/src/models/__snapshots__/role-mapping.ee.test.js.snap new file mode 100644 index 0000000..fceaa4b --- /dev/null +++ b/packages/backend/src/models/__snapshots__/role-mapping.ee.test.js.snap @@ -0,0 +1,30 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = ` +{ + "properties": { + "id": { + "format": "uuid", + "type": "string", + }, + "remoteRoleName": { + "minLength": 1, + "type": "string", + }, + "roleId": { + "format": "uuid", + "type": "string", + }, + "samlAuthProviderId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "samlAuthProviderId", + "roleId", + "remoteRoleName", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/role.test.js.snap b/packages/backend/src/models/__snapshots__/role.test.js.snap new file mode 100644 index 0000000..1a06bee --- /dev/null +++ b/packages/backend/src/models/__snapshots__/role.test.js.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Role model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "description": { + "maxLength": 255, + "type": [ + "string", + "null", + ], + }, + "id": { + "format": "uuid", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/saml-auth-provider.ee.test.js.snap b/packages/backend/src/models/__snapshots__/saml-auth-provider.ee.test.js.snap new file mode 100644 index 0000000..7050fec --- /dev/null +++ b/packages/backend/src/models/__snapshots__/saml-auth-provider.ee.test.js.snap @@ -0,0 +1,72 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SamlAuthProvider model > jsonSchema should have the correct schema 1`] = ` +{ + "properties": { + "active": { + "type": "boolean", + }, + "certificate": { + "minLength": 1, + "type": "string", + }, + "defaultRoleId": { + "format": "uuid", + "type": "string", + }, + "emailAttributeName": { + "minLength": 1, + "type": "string", + }, + "entryPoint": { + "minLength": 1, + "type": "string", + }, + "firstnameAttributeName": { + "minLength": 1, + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "issuer": { + "minLength": 1, + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "roleAttributeName": { + "minLength": 1, + "type": "string", + }, + "signatureAlgorithm": { + "enum": [ + "sha1", + "sha256", + "sha512", + ], + "type": "string", + }, + "surnameAttributeName": { + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "name", + "certificate", + "signatureAlgorithm", + "entryPoint", + "issuer", + "firstnameAttributeName", + "surnameAttributeName", + "emailAttributeName", + "roleAttributeName", + "defaultRoleId", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/saml-auth-providers-role-mapping.ee.test.js.snap b/packages/backend/src/models/__snapshots__/saml-auth-providers-role-mapping.ee.test.js.snap new file mode 100644 index 0000000..fceaa4b --- /dev/null +++ b/packages/backend/src/models/__snapshots__/saml-auth-providers-role-mapping.ee.test.js.snap @@ -0,0 +1,30 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = ` +{ + "properties": { + "id": { + "format": "uuid", + "type": "string", + }, + "remoteRoleName": { + "minLength": 1, + "type": "string", + }, + "roleId": { + "format": "uuid", + "type": "string", + }, + "samlAuthProviderId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "samlAuthProviderId", + "roleId", + "remoteRoleName", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/step.test.js.snap b/packages/backend/src/models/__snapshots__/step.test.js.snap new file mode 100644 index 0000000..d9a4510 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/step.test.js.snap @@ -0,0 +1,85 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Step model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "appKey": { + "maxLength": 255, + "minLength": 1, + "type": [ + "string", + "null", + ], + }, + "connectionId": { + "format": "uuid", + "type": [ + "string", + "null", + ], + }, + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "flowId": { + "format": "uuid", + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "key": { + "type": [ + "string", + "null", + ], + }, + "name": { + "maxLength": 255, + "minLength": 1, + "type": [ + "string", + "null", + ], + }, + "parameters": { + "type": "object", + }, + "position": { + "type": "integer", + }, + "status": { + "default": "incomplete", + "enum": [ + "incomplete", + "completed", + ], + "type": "string", + }, + "type": { + "enum": [ + "action", + "trigger", + ], + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "webhookPath": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "type", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/subscription.ee.test.js.snap b/packages/backend/src/models/__snapshots__/subscription.ee.test.js.snap new file mode 100644 index 0000000..49a64a9 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/subscription.ee.test.js.snap @@ -0,0 +1,63 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Subscription model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "cancelUrl": { + "type": "string", + }, + "cancellationEffectiveDate": { + "type": "string", + }, + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "lastBillDate": { + "type": "string", + }, + "nextBillAmount": { + "type": "string", + }, + "nextBillDate": { + "type": "string", + }, + "paddlePlanId": { + "type": "string", + }, + "paddleSubscriptionId": { + "type": "string", + }, + "status": { + "type": "string", + }, + "updateUrl": { + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "userId", + "paddleSubscriptionId", + "paddlePlanId", + "updateUrl", + "cancelUrl", + "status", + "nextBillAmount", + "nextBillDate", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/usage-data.ee.test.js.snap b/packages/backend/src/models/__snapshots__/usage-data.ee.test.js.snap new file mode 100644 index 0000000..348ffd1 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/usage-data.ee.test.js.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UsageData model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "consumedTaskCount": { + "type": "integer", + }, + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "nextResetAt": { + "type": "string", + }, + "subscriptionId": { + "format": "uuid", + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + "userId": { + "format": "uuid", + "type": "string", + }, + }, + "required": [ + "userId", + "consumedTaskCount", + "nextResetAt", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/__snapshots__/user.test.js.snap b/packages/backend/src/models/__snapshots__/user.test.js.snap new file mode 100644 index 0000000..6636583 --- /dev/null +++ b/packages/backend/src/models/__snapshots__/user.test.js.snap @@ -0,0 +1,81 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`User model > jsonSchema should have correct validations 1`] = ` +{ + "properties": { + "createdAt": { + "type": "string", + }, + "deletedAt": { + "type": "string", + }, + "email": { + "format": "email", + "maxLength": 255, + "minLength": 1, + "type": "string", + }, + "fullName": { + "minLength": 1, + "type": "string", + }, + "id": { + "format": "uuid", + "type": "string", + }, + "invitationToken": { + "type": [ + "string", + "null", + ], + }, + "invitationTokenSentAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "password": { + "minLength": 6, + "type": "string", + }, + "resetPasswordToken": { + "type": [ + "string", + "null", + ], + }, + "resetPasswordTokenSentAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "roleId": { + "format": "uuid", + "type": "string", + }, + "status": { + "default": "active", + "enum": [ + "active", + "invited", + ], + "type": "string", + }, + "trialExpiryDate": { + "type": "string", + }, + "updatedAt": { + "type": "string", + }, + }, + "required": [ + "fullName", + "email", + ], + "type": "object", +} +`; diff --git a/packages/backend/src/models/access-token.js b/packages/backend/src/models/access-token.js new file mode 100644 index 0000000..912e6ac --- /dev/null +++ b/packages/backend/src/models/access-token.js @@ -0,0 +1,67 @@ +import Base from './base.js'; +import User from './user.js'; + +class AccessToken extends Base { + static tableName = 'access_tokens'; + + static jsonSchema = { + type: 'object', + required: ['token', 'expiresIn'], + + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' }, + token: { type: 'string', minLength: 32 }, + samlSessionId: { type: ['string', 'null'] }, + expiresIn: { type: 'integer' }, + revokedAt: { type: ['string', 'null'], format: 'date-time' }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'access_tokens.user_id', + to: 'users.id', + }, + }, + }); + + async terminateRemoteSamlSession() { + if (!this.samlSessionId) { + return; + } + + const user = await this.$relatedQuery('user'); + + const firstIdentity = await user.$relatedQuery('identities').first(); + + const samlAuthProvider = await firstIdentity + .$relatedQuery('samlAuthProvider') + .throwIfNotFound(); + + const response = await samlAuthProvider.terminateRemoteSession( + this.samlSessionId + ); + + return response; + } + + async revoke() { + const response = await this.$query().patch({ + revokedAt: new Date().toISOString(), + }); + + try { + await this.terminateRemoteSamlSession(); + } catch (error) { + // TODO: should it silently fail or not? + } + + return response; + } +} + +export default AccessToken; diff --git a/packages/backend/src/models/access-token.test.js b/packages/backend/src/models/access-token.test.js new file mode 100644 index 0000000..bdfedf2 --- /dev/null +++ b/packages/backend/src/models/access-token.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from 'vitest'; +import AccessToken from './access-token.js'; +import User from './user.js'; +import Base from './base.js'; +import SamlAuthProvider from './saml-auth-provider.ee.js'; +import { createAccessToken } from '../../test/factories/access-token.js'; +import { createUser } from '../../test/factories/user.js'; +import { createIdentity } from '../../test/factories/identity.js'; + +describe('AccessToken model', () => { + it('tableName should return correct name', () => { + expect(AccessToken.tableName).toBe('access_tokens'); + }); + + it('jsonSchema should have correct validations', () => { + expect(AccessToken.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = AccessToken.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'access_tokens.user_id', + to: 'users.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('revoke should set revokedAt and terminate remote SAML session', async () => { + const accessToken = await createAccessToken(); + + const terminateRemoteSamlSessionSpy = vi + .spyOn(accessToken, 'terminateRemoteSamlSession') + .mockImplementation(() => {}); + + await accessToken.revoke(); + + expect(terminateRemoteSamlSessionSpy).toHaveBeenCalledOnce(); + expect(accessToken.revokedAt).not.toBeUndefined(); + }); + + describe('terminateRemoteSamlSession', () => { + it('should terminate remote SAML session when exists', async () => { + const user = await createUser(); + const accessToken = await createAccessToken({ + userId: user.id, + samlSessionId: 'random-remote-session-id', + }); + await createIdentity({ userId: user.id }); + + const terminateRemoteSamlSessionSpy = vi + .spyOn(SamlAuthProvider.prototype, 'terminateRemoteSession') + .mockImplementation(() => {}); + + await accessToken.terminateRemoteSamlSession(); + + expect(terminateRemoteSamlSessionSpy).toHaveBeenCalledWith( + accessToken.samlSessionId + ); + }); + + it(`should return undefined when remote SALM session doesn't exist`, async () => { + const user = await createUser(); + const accessToken = await createAccessToken({ userId: user.id }); + await createIdentity({ userId: user.id }); + + const terminateRemoteSamlSessionSpy = vi + .spyOn(SamlAuthProvider.prototype, 'terminateRemoteSession') + .mockImplementation(() => {}); + + const expected = await accessToken.terminateRemoteSamlSession(); + + expect(terminateRemoteSamlSessionSpy).not.toHaveBeenCalledOnce(); + expect(expected).toBeUndefined(); + }); + }); +}); diff --git a/packages/backend/src/models/app-config.js b/packages/backend/src/models/app-config.js new file mode 100644 index 0000000..c34a0ac --- /dev/null +++ b/packages/backend/src/models/app-config.js @@ -0,0 +1,66 @@ +import App from './app.js'; +import OAuthClient from './oauth-client.js'; +import Base from './base.js'; +import { ValidationError } from 'objection'; + +class AppConfig extends Base { + static tableName = 'app_configs'; + + static get idColumn() { + return 'key'; + } + + static jsonSchema = { + type: 'object', + required: ['key'], + + properties: { + id: { type: 'string', format: 'uuid' }, + key: { type: 'string' }, + useOnlyPredefinedAuthClients: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + oauthClients: { + relation: Base.HasManyRelation, + modelClass: OAuthClient, + join: { + from: 'app_configs.key', + to: 'oauth_clients.app_key', + }, + }, + }); + + async getApp() { + if (!this.key) return null; + + return await App.findOneByKey(this.key); + } + + async createOAuthClient(params) { + const supportsOauthClients = (await this.getApp())?.auth?.generateAuthUrl + ? true + : false; + + if (!supportsOauthClients) { + throw new ValidationError({ + data: { + app: [ + { + message: 'This app does not support OAuth clients!', + }, + ], + }, + type: 'ModelValidation', + }); + } + + return await this.$relatedQuery('oauthClients').insert(params); + } +} + +export default AppConfig; diff --git a/packages/backend/src/models/app-config.test.js b/packages/backend/src/models/app-config.test.js new file mode 100644 index 0000000..a68b393 --- /dev/null +++ b/packages/backend/src/models/app-config.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; + +import Base from './base.js'; +import AppConfig from './app-config.js'; +import App from './app.js'; +import OAuthClient from './oauth-client.js'; + +describe('AppConfig model', () => { + it('tableName should return correct name', () => { + expect(AppConfig.tableName).toBe('app_configs'); + }); + + it('idColumn should return key field', () => { + expect(AppConfig.idColumn).toBe('key'); + }); + + it('jsonSchema should have correct validations', () => { + expect(AppConfig.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = AppConfig.relationMappings(); + + const expectedRelations = { + oauthClients: { + relation: Base.HasManyRelation, + modelClass: OAuthClient, + join: { + from: 'app_configs.key', + to: 'oauth_clients.app_key', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + describe('getApp', () => { + it('getApp should return null if there is no key', async () => { + const appConfig = new AppConfig(); + const app = await appConfig.getApp(); + + expect(app).toBeNull(); + }); + + it('getApp should return app with provided key', async () => { + const appConfig = new AppConfig(); + appConfig.key = 'deepl'; + + const app = await appConfig.getApp(); + const expectedApp = await App.findOneByKey(appConfig.key); + + expect(app).toStrictEqual(expectedApp); + }); + }); +}); diff --git a/packages/backend/src/models/app.js b/packages/backend/src/models/app.js new file mode 100644 index 0000000..6decb5b --- /dev/null +++ b/packages/backend/src/models/app.js @@ -0,0 +1,115 @@ +import fs from 'fs'; +import path, { join } from 'path'; +import { fileURLToPath } from 'url'; +import appInfoConverter from '../helpers/app-info-converter.js'; +import getApp from '../helpers/get-app.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +class App { + static folderPath = join(__dirname, '../apps'); + + static list = fs + .readdirSync(this.folderPath) + .filter((file) => fs.statSync(join(this.folderPath, file)).isDirectory()); + + static async findAll(name, stripFuncs = true) { + if (!name) + return Promise.all( + this.list.map( + async (name) => await this.findOneByName(name, stripFuncs) + ) + ); + + return Promise.all( + this.list + .filter((app) => app.includes(name.toLowerCase())) + .map((name) => this.findOneByName(name, stripFuncs)) + ); + } + + static async findOneByName(name, stripFuncs = false) { + const rawAppData = await getApp(name.toLocaleLowerCase(), stripFuncs); + + return appInfoConverter(rawAppData); + } + + static async findOneByKey(key, stripFuncs = false) { + const rawAppData = await getApp(key, stripFuncs); + + return appInfoConverter(rawAppData); + } + + static async findAuthByKey(key, stripFuncs = false) { + const rawAppData = await getApp(key, stripFuncs); + const appData = appInfoConverter(rawAppData); + + return appData?.auth || {}; + } + + static async findTriggersByKey(key, stripFuncs = false) { + const rawAppData = await getApp(key, stripFuncs); + const appData = appInfoConverter(rawAppData); + + return appData?.triggers || []; + } + + static async findTriggerSubsteps(appKey, triggerKey, stripFuncs = false) { + const rawAppData = await getApp(appKey, stripFuncs); + const appData = appInfoConverter(rawAppData); + + const trigger = appData?.triggers?.find( + (trigger) => trigger.key === triggerKey + ); + + return trigger?.substeps || []; + } + + static async findActionsByKey(key, stripFuncs = false) { + const rawAppData = await getApp(key, stripFuncs); + const appData = appInfoConverter(rawAppData); + + return appData?.actions || []; + } + + static async findActionSubsteps(appKey, actionKey, stripFuncs = false) { + const rawAppData = await getApp(appKey, stripFuncs); + const appData = appInfoConverter(rawAppData); + + const action = appData?.actions?.find((action) => action.key === actionKey); + + return action?.substeps || []; + } + + static async checkAppAndAction(appKey, actionKey) { + const app = await this.findOneByKey(appKey); + + if (!actionKey) return; + + const hasAction = app.actions?.find((action) => action.key === actionKey); + + if (!hasAction) { + throw new Error( + `${app.name} does not have an action with the "${actionKey}" key!` + ); + } + } + + static async checkAppAndTrigger(appKey, triggerKey) { + const app = await this.findOneByKey(appKey); + + if (!triggerKey) return; + + const hasTrigger = app.triggers?.find( + (trigger) => trigger.key === triggerKey + ); + + if (!hasTrigger) { + throw new Error( + `${app.name} does not have a trigger with the "${triggerKey}" key!` + ); + } + } +} + +export default App; diff --git a/packages/backend/src/models/app.test.js b/packages/backend/src/models/app.test.js new file mode 100644 index 0000000..656834c --- /dev/null +++ b/packages/backend/src/models/app.test.js @@ -0,0 +1,418 @@ +import { describe, it, expect, vi } from 'vitest'; + +import App from './app.js'; +import * as getAppModule from '../helpers/get-app.js'; +import * as appInfoConverterModule from '../helpers/app-info-converter.js'; + +describe('App model', () => { + it('folderPath should return correct path', () => { + expect(App.folderPath.endsWith('/packages/backend/src/apps')).toBe(true); + }); + + it('list should have list of applications keys', () => { + expect(App.list).toMatchSnapshot(); + }); + + describe('findAll', () => { + it('should return all applications', async () => { + const apps = await App.findAll(); + + expect(apps.length).toBe(App.list.length); + }); + + it('should return matching applications when name argument is given', async () => { + const apps = await App.findAll('deepl'); + + expect(apps.length).toBe(1); + expect(apps[0].key).toBe('deepl'); + }); + + it('should return matching applications in plain JSON when stripFunc argument is true', async () => { + const appFindOneByNameSpy = vi.spyOn(App, 'findOneByName'); + + await App.findAll('deepl', true); + + expect(appFindOneByNameSpy).toHaveBeenCalledWith('deepl', true); + }); + }); + + describe('findOneByName', () => { + it('should return app info for given app name', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => 'mock-app'); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation(() => 'app-info'); + + const app = await App.findOneByName('DeepL'); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith('mock-app'); + expect(app).toStrictEqual('app-info'); + }); + + it('should return app info for given app name in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => 'mock-app'); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation(() => 'app-info'); + + const app = await App.findOneByName('DeepL', true); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith('mock-app'); + expect(app).toStrictEqual('app-info'); + }); + }); + + describe('findOneByKey', () => { + it('should return app info for given app key', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => 'mock-app'); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation(() => 'app-info'); + + const app = await App.findOneByKey('deepl'); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith('mock-app'); + expect(app).toStrictEqual('app-info'); + }); + + it('should return app info for given app key in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => 'mock-app'); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation(() => 'app-info'); + + const app = await App.findOneByKey('deepl', true); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith('mock-app'); + expect(app).toStrictEqual('app-info'); + }); + }); + + describe('findAuthByKey', () => { + it('should return app auth for given app key', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ auth: 'mock-auth' })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appAuth = await App.findAuthByKey('deepl'); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ auth: 'mock-auth' }); + expect(appAuth).toStrictEqual('mock-auth'); + }); + + it('should return app auth for given app key in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ auth: 'mock-auth' })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appAuth = await App.findAuthByKey('deepl', true); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ auth: 'mock-auth' }); + expect(appAuth).toStrictEqual('mock-auth'); + }); + }); + + describe('findTriggersByKey', () => { + it('should return app triggers for given app key', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ triggers: 'mock-triggers' })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appTriggers = await App.findTriggersByKey('deepl'); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + triggers: 'mock-triggers', + }); + expect(appTriggers).toStrictEqual('mock-triggers'); + }); + + it('should return app triggers for given app key in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ triggers: 'mock-triggers' })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appTriggers = await App.findTriggersByKey('deepl', true); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + triggers: 'mock-triggers', + }); + expect(appTriggers).toStrictEqual('mock-triggers'); + }); + }); + + describe('findTriggerSubsteps', () => { + it('should return app trigger substeps for given app key', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ + triggers: [{ key: 'mock-trigger', substeps: 'mock-substeps' }], + })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appTriggerSubsteps = await App.findTriggerSubsteps( + 'deepl', + 'mock-trigger' + ); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + triggers: [{ key: 'mock-trigger', substeps: 'mock-substeps' }], + }); + expect(appTriggerSubsteps).toStrictEqual('mock-substeps'); + }); + + it('should return app trigger substeps for given app key in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ + triggers: [{ key: 'mock-trigger', substeps: 'mock-substeps' }], + })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appTriggerSubsteps = await App.findTriggerSubsteps( + 'deepl', + 'mock-trigger', + true + ); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + triggers: [{ key: 'mock-trigger', substeps: 'mock-substeps' }], + }); + expect(appTriggerSubsteps).toStrictEqual('mock-substeps'); + }); + }); + + describe('findActionsByKey', () => { + it('should return app actions for given app key', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ actions: 'mock-actions' })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appActions = await App.findActionsByKey('deepl'); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + actions: 'mock-actions', + }); + expect(appActions).toStrictEqual('mock-actions'); + }); + + it('should return app actions for given app key in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ actions: 'mock-actions' })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appActions = await App.findActionsByKey('deepl', true); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + actions: 'mock-actions', + }); + expect(appActions).toStrictEqual('mock-actions'); + }); + }); + + describe('findActionSubsteps', () => { + it('should return app action substeps for given app key', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ + actions: [{ key: 'mock-action', substeps: 'mock-substeps' }], + })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appActionSubsteps = await App.findActionSubsteps( + 'deepl', + 'mock-action' + ); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', false); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + actions: [{ key: 'mock-action', substeps: 'mock-substeps' }], + }); + expect(appActionSubsteps).toStrictEqual('mock-substeps'); + }); + + it('should return app action substeps for given app key in plain JSON when stripFunc argument is true', async () => { + const getAppSpy = vi + .spyOn(getAppModule, 'default') + .mockImplementation(() => ({ + actions: [{ key: 'mock-action', substeps: 'mock-substeps' }], + })); + + const appInfoConverterSpy = vi + .spyOn(appInfoConverterModule, 'default') + .mockImplementation((input) => input); + + const appActionSubsteps = await App.findActionSubsteps( + 'deepl', + 'mock-action', + true + ); + + expect(getAppSpy).toHaveBeenCalledWith('deepl', true); + expect(appInfoConverterSpy).toHaveBeenCalledWith({ + actions: [{ key: 'mock-action', substeps: 'mock-substeps' }], + }); + expect(appActionSubsteps).toStrictEqual('mock-substeps'); + }); + }); + + describe('checkAppAndAction', () => { + it('should return undefined when app and action exist', async () => { + const findOneByKeySpy = vi + .spyOn(App, 'findOneByKey') + .mockImplementation(() => ({ + actions: [ + { + key: 'translate-text', + }, + ], + })); + + const appAndActionExist = await App.checkAppAndAction( + 'deepl', + 'translate-text' + ); + + expect(findOneByKeySpy).toHaveBeenCalledWith('deepl'); + expect(appAndActionExist).toBeUndefined(); + }); + + it('should return undefined when app exists without action argument provided', async () => { + const actionFindSpy = vi.fn(); + const findOneByKeySpy = vi + .spyOn(App, 'findOneByKey') + .mockImplementation(() => ({ + actions: { + find: actionFindSpy, + }, + })); + + const appAndActionExist = await App.checkAppAndAction('deepl'); + + expect(findOneByKeySpy).toHaveBeenCalledWith('deepl'); + expect(actionFindSpy).not.toHaveBeenCalled(); + expect(appAndActionExist).toBeUndefined(); + }); + + it('should throw an error when app exists, but action does not', async () => { + const findOneByKeySpy = vi + .spyOn(App, 'findOneByKey') + .mockImplementation(() => ({ name: 'deepl' })); + + await expect(() => + App.checkAppAndAction('deepl', 'non-existing-action') + ).rejects.toThrowError( + 'deepl does not have an action with the "non-existing-action" key!' + ); + expect(findOneByKeySpy).toHaveBeenCalledWith('deepl'); + }); + }); + + describe('checkAppAndTrigger', () => { + it('should return undefined when app and trigger exist', async () => { + const findOneByKeySpy = vi + .spyOn(App, 'findOneByKey') + .mockImplementation(() => ({ + triggers: [ + { + key: 'catch-raw-webhook', + }, + ], + })); + + const appAndTriggerExist = await App.checkAppAndTrigger( + 'webhook', + 'catch-raw-webhook' + ); + + expect(findOneByKeySpy).toHaveBeenCalledWith('webhook'); + expect(appAndTriggerExist).toBeUndefined(); + }); + + it('should return undefined when app exists without trigger argument provided', async () => { + const triggerFindSpy = vi.fn(); + const findOneByKeySpy = vi + .spyOn(App, 'findOneByKey') + .mockImplementation(() => ({ + actions: { + find: triggerFindSpy, + }, + })); + + const appAndTriggerExist = await App.checkAppAndTrigger('webhook'); + + expect(findOneByKeySpy).toHaveBeenCalledWith('webhook'); + expect(triggerFindSpy).not.toHaveBeenCalled(); + expect(appAndTriggerExist).toBeUndefined(); + }); + + it('should throw an error when app exists, but trigger does not', async () => { + const findOneByKeySpy = vi + .spyOn(App, 'findOneByKey') + .mockImplementation(() => ({ name: 'webhook' })); + + await expect(() => + App.checkAppAndTrigger('webhook', 'non-existing-trigger') + ).rejects.toThrowError( + 'webhook does not have a trigger with the "non-existing-trigger" key!' + ); + expect(findOneByKeySpy).toHaveBeenCalledWith('webhook'); + }); + }); +}); diff --git a/packages/backend/src/models/base.js b/packages/backend/src/models/base.js new file mode 100644 index 0000000..7cd22d7 --- /dev/null +++ b/packages/backend/src/models/base.js @@ -0,0 +1,40 @@ +import { AjvValidator, Model, snakeCaseMappers } from 'objection'; +import addFormats from 'ajv-formats'; + +import ExtendedQueryBuilder from './query-builder.js'; + +class Base extends Model { + static QueryBuilder = ExtendedQueryBuilder; + + static get columnNameMappers() { + return snakeCaseMappers(); + } + + static createValidator() { + return new AjvValidator({ + onCreateAjv: (ajv) => { + addFormats.default(ajv); + }, + options: { + allErrors: true, + validateSchema: true, + ownProperties: true, + }, + }); + } + + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + + this.createdAt = new Date().toISOString(); + this.updatedAt = new Date().toISOString(); + } + + async $beforeUpdate(opts, queryContext) { + this.updatedAt = new Date().toISOString(); + + await super.$beforeUpdate(opts, queryContext); + } +} + +export default Base; diff --git a/packages/backend/src/models/config.js b/packages/backend/src/models/config.js new file mode 100644 index 0000000..f60e51b --- /dev/null +++ b/packages/backend/src/models/config.js @@ -0,0 +1,84 @@ +import appConfig from '../config/app.js'; +import Base from './base.js'; + +class Config extends Base { + static tableName = 'config'; + + static jsonSchema = { + type: 'object', + + properties: { + id: { type: 'string', format: 'uuid' }, + installationCompleted: { type: 'boolean' }, + logoSvgData: { type: ['string', 'null'] }, + palettePrimaryDark: { type: ['string', 'null'] }, + palettePrimaryLight: { type: ['string', 'null'] }, + palettePrimaryMain: { type: ['string', 'null'] }, + title: { type: ['string', 'null'] }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static get virtualAttributes() { + return [ + 'disableNotificationsPage', + 'disableFavicon', + 'additionalDrawerLink', + 'additionalDrawerLinkIcon', + 'additionalDrawerLinkText', + ]; + } + + get disableNotificationsPage() { + return appConfig.disableNotificationsPage; + } + + get disableFavicon() { + return appConfig.disableFavicon; + } + + get additionalDrawerLink() { + return appConfig.additionalDrawerLink; + } + + get additionalDrawerLinkIcon() { + return appConfig.additionalDrawerLinkIcon; + } + + get additionalDrawerLinkText() { + return appConfig.additionalDrawerLinkText; + } + + static async get() { + const existingConfig = await this.query().limit(1).first(); + + if (!existingConfig) { + return await this.query().insertAndFetch({}); + } + + return existingConfig; + } + + static async update(config) { + const configEntry = await this.get(); + + return await configEntry.$query().patchAndFetch(config); + } + + static async isInstallationCompleted() { + const config = await this.get(); + + return config.installationCompleted; + } + + static async markInstallationCompleted() { + const config = await this.get(); + + return await config.$query().patchAndFetch({ + installationCompleted: true, + }); + } +} + +export default Config; diff --git a/packages/backend/src/models/config.test.js b/packages/backend/src/models/config.test.js new file mode 100644 index 0000000..596bf16 --- /dev/null +++ b/packages/backend/src/models/config.test.js @@ -0,0 +1,137 @@ +import { describe, it, expect, vi } from 'vitest'; +import appConfig from '../config/app.js'; +import Config from './config'; +import { createConfig } from '../../test/factories/config.js'; + +describe('Config model', () => { + it('tableName should return correct name', () => { + expect(Config.tableName).toBe('config'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Config.jsonSchema).toMatchSnapshot(); + }); + + it('virtualAttributes should return correct attributes', () => { + const virtualAttributes = Config.virtualAttributes; + + const expectedAttributes = [ + 'disableNotificationsPage', + 'disableFavicon', + 'additionalDrawerLink', + 'additionalDrawerLinkIcon', + 'additionalDrawerLinkText', + ]; + + expect(virtualAttributes).toStrictEqual(expectedAttributes); + }); + + it('disableNotificationsPage should return its value in appConfig', async () => { + const disableNotificationsPageSpy = vi.spyOn( + appConfig, + 'disableNotificationsPage', + 'get' + ); + + new Config().disableNotificationsPage; + + expect(disableNotificationsPageSpy).toHaveBeenCalledOnce(); + }); + + it('disableFavicon should return its value in appConfig', async () => { + const disableFaviconSpy = vi + .spyOn(appConfig, 'disableFavicon', 'get') + .mockReturnValue(true); + + new Config().disableFavicon; + + expect(disableFaviconSpy).toHaveBeenCalledOnce(); + }); + + it('additionalDrawerLink should return its value in appConfig', async () => { + const additionalDrawerLinkSpy = vi + .spyOn(appConfig, 'additionalDrawerLink', 'get') + .mockReturnValue('https://automatisch.io'); + + new Config().additionalDrawerLink; + + expect(additionalDrawerLinkSpy).toHaveBeenCalledOnce(); + }); + + it('additionalDrawerLinkIcon should return its value in appConfig', async () => { + const additionalDrawerLinkIconSpy = vi + .spyOn(appConfig, 'additionalDrawerLinkIcon', 'get') + .mockReturnValue('SampleIcon'); + + new Config().additionalDrawerLinkIcon; + + expect(additionalDrawerLinkIconSpy).toHaveBeenCalledOnce(); + }); + + it('additionalDrawerLinkText should return its value in appConfig', async () => { + const additionalDrawerLinkTextSpy = vi + .spyOn(appConfig, 'additionalDrawerLinkText', 'get') + .mockReturnValue('Go back to Automatisch'); + + new Config().additionalDrawerLinkText; + + expect(additionalDrawerLinkTextSpy).toHaveBeenCalledOnce(); + }); + + describe('get', () => { + it('should return single config record when it exists', async () => { + const createdConfig = await createConfig({ + title: 'Automatisch', + }); + + const config = await Config.get(); + + expect(config).toStrictEqual(createdConfig); + }); + + it('should create config record and return when it does not exist', async () => { + const configBefore = await Config.query().first(); + + expect(configBefore).toBeUndefined(); + + const config = await Config.get(); + + expect(config).toBeTruthy(); + }); + }); + + it('update should update existing single record', async () => { + const patchAndFetchSpy = vi + .fn() + .mockImplementation((newConfig) => newConfig); + + vi.spyOn(Config, 'get').mockImplementation(() => ({ + $query: () => ({ + patchAndFetch: patchAndFetchSpy, + }), + })); + + const config = await Config.update({ title: 'Automatisch' }); + + expect(patchAndFetchSpy).toHaveBeenCalledWith({ title: 'Automatisch' }); + expect(config).toStrictEqual({ title: 'Automatisch' }); + }); + + it('isInstallationCompleted should return installationCompleted value', async () => { + const configGetSpy = vi.spyOn(Config, 'get').mockImplementation(() => ({ + installationCompleted: true, + })); + + await Config.isInstallationCompleted(); + + expect(configGetSpy).toHaveBeenCalledOnce(); + }); + + it('markInstallationCompleted should update installationCompleted as true', async () => { + await Config.update({ installationCompleted: false }); + + const config = await Config.markInstallationCompleted(); + + expect(config.installationCompleted).toBe(true); + }); +}); diff --git a/packages/backend/src/models/connection.js b/packages/backend/src/models/connection.js new file mode 100644 index 0000000..5b4c7c6 --- /dev/null +++ b/packages/backend/src/models/connection.js @@ -0,0 +1,265 @@ +import AES from 'crypto-js/aes.js'; +import enc from 'crypto-js/enc-utf8.js'; +import App from './app.js'; +import AppConfig from './app-config.js'; +import OAuthClient from './oauth-client.js'; +import Base from './base.js'; +import User from './user.js'; +import Step from './step.js'; +import appConfig from '../config/app.js'; +import Telemetry from '../helpers/telemetry/index.js'; +import globalVariable from '../helpers/global-variable.js'; +import NotAuthorizedError from '../errors/not-authorized.js'; + +class Connection extends Base { + static tableName = 'connections'; + + static jsonSchema = { + type: 'object', + required: ['key'], + + properties: { + id: { type: 'string', format: 'uuid' }, + key: { type: 'string', minLength: 1, maxLength: 255 }, + data: { type: 'string' }, + formattedData: { type: 'object' }, + userId: { type: 'string', format: 'uuid' }, + oauthClientId: { type: 'string', format: 'uuid' }, + verified: { type: 'boolean', default: false }, + draft: { type: 'boolean' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'connections.user_id', + to: 'users.id', + }, + }, + steps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'connections.id', + to: 'steps.connection_id', + }, + }, + triggerSteps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'connections.id', + to: 'steps.connection_id', + }, + filter(builder) { + builder.where('type', '=', 'trigger'); + }, + }, + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'connections.key', + to: 'app_configs.key', + }, + }, + oauthClient: { + relation: Base.BelongsToOneRelation, + modelClass: OAuthClient, + join: { + from: 'connections.oauth_client_id', + to: 'oauth_clients.id', + }, + }, + }); + + encryptData() { + if (!this.eligibleForEncryption()) return; + + this.data = AES.encrypt( + JSON.stringify(this.formattedData), + appConfig.encryptionKey + ).toString(); + + delete this.formattedData; + } + + decryptData() { + if (!this.eligibleForDecryption()) return; + + this.formattedData = JSON.parse( + AES.decrypt(this.data, appConfig.encryptionKey).toString(enc) + ); + } + + eligibleForEncryption() { + return this.formattedData ? true : false; + } + + eligibleForDecryption() { + return this.data ? true : false; + } + + async getApp() { + if (!this.key) return null; + + return await App.findOneByKey(this.key); + } + + async getAppConfig() { + return await AppConfig.query().findOne({ key: this.key }); + } + + async checkEligibilityForCreation() { + const app = await this.getApp(); + + const appConfig = await this.getAppConfig(); + + if (appConfig) { + if (appConfig.disabled) { + throw new NotAuthorizedError( + 'The application has been disabled for new connections!' + ); + } + + if (appConfig.useOnlyPredefinedAuthClients && this.formattedData) { + throw new NotAuthorizedError( + `New custom connections have been disabled for ${app.name}!` + ); + } + + if (!this.formattedData) { + const authClient = await appConfig + .$relatedQuery('oauthClients') + .findById(this.oauthClientId) + .where({ active: true }) + .throwIfNotFound(); + + this.formattedData = authClient.formattedAuthDefaults; + } + } + + return this; + } + + async testAndUpdateConnection() { + const app = await this.getApp(); + const $ = await globalVariable({ connection: this, app }); + + let isStillVerified; + + try { + isStillVerified = !!(await app.auth.isStillVerified($)); + } catch { + isStillVerified = false; + } + + return await this.$query().patchAndFetch({ + formattedData: this.formattedData, + verified: isStillVerified, + }); + } + + async verifyAndUpdateConnection() { + const app = await this.getApp(); + const $ = await globalVariable({ connection: this, app }); + await app.auth.verifyCredentials($); + + return await this.$query().patchAndFetch({ + verified: true, + draft: false, + }); + } + + async verifyWebhook(request) { + if (!this.key) return true; + + const app = await this.getApp(); + + const $ = await globalVariable({ + connection: this, + request, + }); + + if (!app.auth?.verifyWebhook) return true; + + return app.auth.verifyWebhook($); + } + + async generateAuthUrl() { + const app = await this.getApp(); + const $ = await globalVariable({ connection: this, app }); + + await app.auth.generateAuthUrl($); + + const url = this.formattedData.url; + + return { url }; + } + + async reset() { + const formattedData = this?.formattedData?.screenName + ? { screenName: this.formattedData.screenName } + : {}; + + const updatedConnection = await this.$query().patchAndFetch({ + formattedData, + }); + + return updatedConnection; + } + + async updateFormattedData({ formattedData, oauthClientId }) { + if (oauthClientId) { + const oauthClient = await OAuthClient.query() + .findById(oauthClientId) + .throwIfNotFound(); + + formattedData = oauthClient.formattedAuthDefaults; + } + + return await this.$query().patchAndFetch({ + formattedData: { + ...this.formattedData, + ...formattedData, + }, + }); + } + + // TODO: Make another abstraction like beforeSave instead of using + // beforeInsert and beforeUpdate separately for the same operation. + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + + await this.checkEligibilityForCreation(); + + this.encryptData(); + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + this.encryptData(); + } + + async $afterFind() { + this.decryptData(); + } + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + Telemetry.connectionCreated(this); + } + + async $afterUpdate(opt, queryContext) { + await super.$afterUpdate(opt, queryContext); + Telemetry.connectionUpdated(this); + } +} + +export default Connection; diff --git a/packages/backend/src/models/connection.test.js b/packages/backend/src/models/connection.test.js new file mode 100644 index 0000000..58410ee --- /dev/null +++ b/packages/backend/src/models/connection.test.js @@ -0,0 +1,702 @@ +import { describe, it, expect, vi } from 'vitest'; +import AES from 'crypto-js/aes.js'; +import enc from 'crypto-js/enc-utf8.js'; +import appConfig from '../config/app.js'; +import OAuthClient from './oauth-client.js'; +import App from './app.js'; +import AppConfig from './app-config.js'; +import Base from './base.js'; +import Connection from './connection'; +import Step from './step.js'; +import User from './user.js'; +import Telemetry from '../helpers/telemetry/index.js'; +import { createConnection } from '../../test/factories/connection.js'; +import { createAppConfig } from '../../test/factories/app-config.js'; +import { createOAuthClient } from '../../test/factories/oauth-client.js'; + +describe('Connection model', () => { + it('tableName should return correct name', () => { + expect(Connection.tableName).toBe('connections'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Connection.jsonSchema).toMatchSnapshot(); + }); + + describe('relationMappings', () => { + it('should return correct associations', () => { + const relationMappings = Connection.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'connections.user_id', + to: 'users.id', + }, + }, + steps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'connections.id', + to: 'steps.connection_id', + }, + }, + triggerSteps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'connections.id', + to: 'steps.connection_id', + }, + filter: expect.any(Function), + }, + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'connections.key', + to: 'app_configs.key', + }, + }, + oauthClient: { + relation: Base.BelongsToOneRelation, + modelClass: OAuthClient, + join: { + from: 'connections.oauth_client_id', + to: 'oauth_clients.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('triggerSteps should return only trigger typed steps', () => { + const relations = Connection.relationMappings(); + const whereSpy = vi.fn(); + + relations.triggerSteps.filter({ where: whereSpy }); + + expect(whereSpy).toHaveBeenCalledWith('type', '=', 'trigger'); + }); + }); + + describe('encryptData', () => { + it('should return undefined if eligibleForEncryption is not true', async () => { + vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue( + false + ); + + const connection = new Connection(); + + expect(connection.encryptData()).toBeUndefined(); + }); + + it('should encrypt formattedData and set it to data', async () => { + vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue( + true + ); + + const formattedData = { + key: 'value', + }; + + const connection = new Connection(); + connection.formattedData = formattedData; + connection.encryptData(); + + const expectedDecryptedValue = JSON.parse( + AES.decrypt(connection.data, appConfig.encryptionKey).toString(enc) + ); + + expect(formattedData).toStrictEqual(expectedDecryptedValue); + expect(connection.data).not.toStrictEqual(formattedData); + }); + + it('should encrypt formattedData and remove formattedData', async () => { + vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue( + true + ); + + const formattedData = { + key: 'value', + }; + + const connection = new Connection(); + connection.formattedData = formattedData; + connection.encryptData(); + + expect(connection.formattedData).not.toBeDefined(); + }); + }); + + describe('decryptData', () => { + it('should return undefined if eligibleForDecryption is not true', () => { + vi.spyOn(Connection.prototype, 'eligibleForDecryption').mockReturnValue( + false + ); + + const connection = new Connection(); + + expect(connection.decryptData()).toBeUndefined(); + }); + + it('should decrypt data and set it to formattedData', async () => { + vi.spyOn(Connection.prototype, 'eligibleForDecryption').mockReturnValue( + true + ); + + const formattedData = { + key: 'value', + }; + + const data = AES.encrypt( + JSON.stringify(formattedData), + appConfig.encryptionKey + ).toString(); + + const connection = new Connection(); + connection.data = data; + connection.decryptData(); + + expect(connection.formattedData).toStrictEqual(formattedData); + expect(connection.data).not.toStrictEqual(formattedData); + }); + }); + + describe('eligibleForEncryption', () => { + it('should return true when formattedData property exists', async () => { + const connection = new Connection(); + connection.formattedData = { clientId: 'sample-id' }; + + expect(connection.eligibleForEncryption()).toBe(true); + }); + + it("should return false when formattedData property doesn't exist", async () => { + const connection = new Connection(); + connection.formattedData = undefined; + + expect(connection.eligibleForEncryption()).toBe(false); + }); + }); + + describe('eligibleForDecryption', () => { + it('should return true when data property exists', async () => { + const connection = new Connection(); + connection.data = 'encrypted-data'; + + expect(connection.eligibleForDecryption()).toBe(true); + }); + + it("should return false when data property doesn't exist", async () => { + const connection = new Connection(); + connection.data = undefined; + + expect(connection.eligibleForDecryption()).toBe(false); + }); + }); + + describe('getApp', () => { + it('should return connection app when valid key exists', async () => { + const connection = new Connection(); + connection.key = 'gitlab'; + + const connectionApp = await connection.getApp(); + const app = await App.findOneByKey('gitlab'); + + expect(connectionApp).toStrictEqual(app); + }); + + it('should throw an error when invalid key exists', async () => { + const connection = new Connection(); + connection.key = 'invalid-key'; + + await expect(() => connection.getApp()).rejects.toThrowError( + `An application with the "invalid-key" key couldn't be found.` + ); + }); + + it('should return null when no key exists', async () => { + const connection = new Connection(); + + await expect(connection.getApp()).resolves.toBe(null); + }); + }); + + it('getAppConfig should return connection app config', async () => { + const connection = new Connection(); + connection.key = 'gitlab'; + + const appConfig = await createAppConfig({ key: 'gitlab' }); + + const connectionAppConfig = await connection.getAppConfig(); + + expect(connectionAppConfig).toStrictEqual(appConfig); + }); + + describe('checkEligibilityForCreation', () => { + it('should return connection if no app config exists', async () => { + vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({ + name: 'gitlab', + }); + + vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue(); + + const connection = new Connection(); + + expect(await connection.checkEligibilityForCreation()).toBe(connection); + }); + + it('should throw an error when app does not exist', async () => { + vi.spyOn(Connection.prototype, 'getApp').mockRejectedValue( + new Error( + `An application with the "unexisting-app" key couldn't be found.` + ) + ); + + vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue(); + + const connection = new Connection(); + + await expect(() => + connection.checkEligibilityForCreation() + ).rejects.toThrow( + `An application with the "unexisting-app" key couldn't be found.` + ); + }); + + it('should throw an error when app config is disabled', async () => { + vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({ + name: 'gitlab', + }); + + vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({ + disabled: true, + }); + + const connection = new Connection(); + + await expect(() => + connection.checkEligibilityForCreation() + ).rejects.toThrow( + 'The application has been disabled for new connections!' + ); + }); + + // TODO: update test case name + it('should throw an error when app config does not allow custom connection with formatted data', async () => { + vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({ + name: 'gitlab', + }); + + vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({ + disabled: false, + useOnlyPredefinedAuthClients: true, + }); + + const connection = new Connection(); + connection.formattedData = {}; + + await expect(() => + connection.checkEligibilityForCreation() + ).rejects.toThrow( + 'New custom connections have been disabled for gitlab!' + ); + }); + + it('should apply oauth client auth defaults when creating with shared oauth client', async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + }); + + const oauthClient = await createOAuthClient({ + appKey: 'gitlab', + active: true, + formattedAuthDefaults: { + clientId: 'sample-id', + }, + }); + + const connection = await createConnection({ + key: 'gitlab', + oauthClientId: oauthClient.id, + formattedData: null, + }); + + await connection.checkEligibilityForCreation(); + + expect(connection.formattedData).toStrictEqual({ + clientId: 'sample-id', + }); + }); + }); + + describe('testAndUpdateConnection', () => { + it('should verify connection and persist it', async () => { + const connection = await createConnection({ verified: false }); + + const isStillVerifiedSpy = vi.fn().mockReturnValue(true); + + const originalApp = await connection.getApp(); + + const getAppSpy = vi + .spyOn(connection, 'getApp') + .mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + isStillVerified: isStillVerifiedSpy, + }, + }; + }); + + const updatedConnection = await connection.testAndUpdateConnection(); + + expect(getAppSpy).toHaveBeenCalledOnce(); + expect(isStillVerifiedSpy).toHaveBeenCalledOnce(); + expect(updatedConnection.verified).toBe(true); + }); + + it('should unverify connection and persist it', async () => { + const connection = await createConnection({ verified: true }); + + const isStillVerifiedSpy = vi + .fn() + .mockRejectedValue(new Error('Wrong credentials!')); + + const originalApp = await connection.getApp(); + + const getAppSpy = vi + .spyOn(connection, 'getApp') + .mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + isStillVerified: isStillVerifiedSpy, + }, + }; + }); + + const updatedConnection = await connection.testAndUpdateConnection(); + + expect(getAppSpy).toHaveBeenCalledOnce(); + expect(isStillVerifiedSpy).toHaveBeenCalledOnce(); + expect(updatedConnection.verified).toBe(false); + }); + }); + + describe('verifyAndUpdateConnection', () => { + it('should verify connection with valid token', async () => { + const connection = await createConnection({ + verified: false, + draft: true, + }); + + const verifyCredentialsSpy = vi.fn().mockResolvedValue(true); + + const originalApp = await connection.getApp(); + + vi.spyOn(connection, 'getApp').mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + verifyCredentials: verifyCredentialsSpy, + }, + }; + }); + + const updatedConnection = await connection.verifyAndUpdateConnection(); + + expect(verifyCredentialsSpy).toHaveBeenCalledOnce(); + expect(updatedConnection.verified).toBe(true); + expect(updatedConnection.draft).toBe(false); + }); + + it('should throw an error with invalid token', async () => { + const connection = await createConnection({ + verified: false, + draft: true, + }); + + const verifyCredentialsSpy = vi + .fn() + .mockRejectedValue(new Error('Invalid token!')); + + const originalApp = await connection.getApp(); + + vi.spyOn(connection, 'getApp').mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + verifyCredentials: verifyCredentialsSpy, + }, + }; + }); + + await expect(() => + connection.verifyAndUpdateConnection() + ).rejects.toThrowError('Invalid token!'); + expect(verifyCredentialsSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('verifyWebhook', () => { + it('should verify webhook on remote', async () => { + const connection = await createConnection({ key: 'typeform' }); + + const verifyWebhookSpy = vi.fn().mockResolvedValue('verified-webhook'); + + const originalApp = await connection.getApp(); + + vi.spyOn(connection, 'getApp').mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + verifyWebhook: verifyWebhookSpy, + }, + }; + }); + + expect(await connection.verifyWebhook()).toBe('verified-webhook'); + }); + + it('should return true if connection does not have value in key property', async () => { + const connection = await createConnection({ key: null }); + + expect(await connection.verifyWebhook()).toBe(true); + }); + + it('should throw an error at failed webhook verification', async () => { + const connection = await createConnection({ key: 'typeform' }); + + const verifyWebhookSpy = vi.fn().mockRejectedValue('unverified-webhook'); + + const originalApp = await connection.getApp(); + + vi.spyOn(connection, 'getApp').mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + verifyWebhook: verifyWebhookSpy, + }, + }; + }); + + await expect(() => connection.verifyWebhook()).rejects.toThrowError( + 'unverified-webhook' + ); + }); + }); + + it('generateAuthUrl should return authentication url', async () => { + const connection = await createConnection({ + key: 'typeform', + formattedData: { + url: 'https://automatisch.io/authentication-url', + }, + }); + + const generateAuthUrlSpy = vi.fn(); + + const originalApp = await connection.getApp(); + + vi.spyOn(connection, 'getApp').mockImplementation(() => { + return { + ...originalApp, + auth: { + ...originalApp.auth, + generateAuthUrl: generateAuthUrlSpy, + }, + }; + }); + + expect(await connection.generateAuthUrl()).toStrictEqual({ + url: 'https://automatisch.io/authentication-url', + }); + }); + + describe('reset', () => { + it('should keep screen name when exists and reset the rest of the formatted data', async () => { + const connection = await createConnection({ + formattedData: { + screenName: 'Sample connection', + token: 'sample-token', + }, + }); + + await connection.reset(); + + const refetchedConnection = await connection.$query(); + + expect(refetchedConnection.formattedData).toStrictEqual({ + screenName: 'Sample connection', + }); + }); + + it('should empty formatted data object when screen name does not exist', async () => { + const connection = await createConnection({ + formattedData: { + token: 'sample-token', + }, + }); + + await connection.reset(); + + const refetchedConnection = await connection.$query(); + + expect(refetchedConnection.formattedData).toStrictEqual({}); + }); + }); + + describe('updateFormattedData', () => { + it('should extend connection data with oauth client auth defaults', async () => { + const oauthClient = await createOAuthClient({ + formattedAuthDefaults: { + clientId: 'sample-id', + }, + }); + + const connection = await createConnection({ + oauthClientId: oauthClient.id, + formattedData: { + token: 'sample-token', + }, + }); + + const updatedConnection = await connection.updateFormattedData({ + oauthClientId: oauthClient.id, + }); + + expect(updatedConnection.formattedData).toStrictEqual({ + clientId: 'sample-id', + token: 'sample-token', + }); + }); + }); + + describe('$beforeInsert', () => { + it('should call super.$beforeInsert', async () => { + const superBeforeInsertSpy = vi + .spyOn(Base.prototype, '$beforeInsert') + .mockResolvedValue(); + + await createConnection(); + + expect(superBeforeInsertSpy).toHaveBeenCalledOnce(); + }); + + it('should call checkEligibilityForCreation', async () => { + const checkEligibilityForCreationSpy = vi + .spyOn(Connection.prototype, 'checkEligibilityForCreation') + .mockResolvedValue(); + + await createConnection(); + + expect(checkEligibilityForCreationSpy).toHaveBeenCalledOnce(); + }); + + it('should call encryptData', async () => { + const encryptDataSpy = vi + .spyOn(Connection.prototype, 'encryptData') + .mockResolvedValue(); + + await createConnection(); + + expect(encryptDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('$beforeUpdate', () => { + it('should call super.$beforeUpdate', async () => { + const superBeforeUpdateSpy = vi + .spyOn(Base.prototype, '$beforeUpdate') + .mockResolvedValue(); + + const connection = await createConnection(); + + await connection.$query().patch({ verified: false }); + + expect(superBeforeUpdateSpy).toHaveBeenCalledOnce(); + }); + + it('should call encryptData', async () => { + const connection = await createConnection(); + + const encryptDataSpy = vi + .spyOn(Connection.prototype, 'encryptData') + .mockResolvedValue(); + + await connection.$query().patch({ verified: false }); + + expect(encryptDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('$afterFind', () => { + it('should call decryptData', async () => { + const connection = await createConnection(); + + const decryptDataSpy = vi + .spyOn(Connection.prototype, 'decryptData') + .mockResolvedValue(); + + await connection.$query(); + + expect(decryptDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('$afterInsert', () => { + it('should call super.$afterInsert', async () => { + const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert'); + + await createConnection(); + + expect(superAfterInsertSpy).toHaveBeenCalledOnce(); + }); + + it('should call Telemetry.connectionCreated', async () => { + const telemetryConnectionCreatedSpy = vi + .spyOn(Telemetry, 'connectionCreated') + .mockImplementation(() => {}); + + const connection = await createConnection(); + + expect(telemetryConnectionCreatedSpy).toHaveBeenCalledWith(connection); + }); + }); + + describe('$afterUpdate', () => { + it('should call super.$afterUpdate', async () => { + const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterUpdate'); + + const connection = await createConnection(); + + await connection.$query().patch({ verified: false }); + + expect(superAfterInsertSpy).toHaveBeenCalledOnce(); + }); + + it('should call Telemetry.connectionUpdated', async () => { + const telemetryconnectionUpdatedSpy = vi + .spyOn(Telemetry, 'connectionCreated') + .mockImplementation(() => {}); + + const connection = await createConnection(); + + await connection.$query().patch({ verified: false }); + + expect(telemetryconnectionUpdatedSpy).toHaveBeenCalledWith(connection); + }); + }); +}); diff --git a/packages/backend/src/models/datastore.js b/packages/backend/src/models/datastore.js new file mode 100644 index 0000000..aad86de --- /dev/null +++ b/packages/backend/src/models/datastore.js @@ -0,0 +1,24 @@ +import Base from './base.js'; + +class Datastore extends Base { + static tableName = 'datastore'; + + static jsonSchema = { + type: 'object', + required: ['key', 'value', 'scopeId'], + + properties: { + id: { type: 'string', format: 'uuid' }, + key: { type: 'string', minLength: 1 }, + value: { type: 'string' }, + scope: { + type: 'string', + enum: ['flow'], + default: 'flow', + }, + scopeId: { type: 'string', format: 'uuid' }, + }, + }; +} + +export default Datastore; diff --git a/packages/backend/src/models/datastore.test.js b/packages/backend/src/models/datastore.test.js new file mode 100644 index 0000000..ba02e2f --- /dev/null +++ b/packages/backend/src/models/datastore.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import Datastore from './datastore'; + +describe('Datastore model', () => { + it('tableName should return correct name', () => { + expect(Datastore.tableName).toBe('datastore'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Datastore.jsonSchema).toMatchSnapshot(); + }); +}); diff --git a/packages/backend/src/models/execution-step.js b/packages/backend/src/models/execution-step.js new file mode 100644 index 0000000..b17343b --- /dev/null +++ b/packages/backend/src/models/execution-step.js @@ -0,0 +1,78 @@ +import appConfig from '../config/app.js'; +import Base from './base.js'; +import Execution from './execution.js'; +import Step from './step.js'; +import Telemetry from '../helpers/telemetry/index.js'; + +class ExecutionStep extends Base { + static tableName = 'execution_steps'; + + static jsonSchema = { + type: 'object', + + properties: { + id: { type: 'string', format: 'uuid' }, + executionId: { type: 'string', format: 'uuid' }, + stepId: { type: 'string' }, + dataIn: { type: ['object', 'null'] }, + dataOut: { type: ['object', 'null'] }, + status: { type: 'string', enum: ['success', 'failure'] }, + errorDetails: { type: ['object', 'null'] }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + execution: { + relation: Base.BelongsToOneRelation, + modelClass: Execution, + join: { + from: 'execution_steps.execution_id', + to: 'executions.id', + }, + }, + step: { + relation: Base.BelongsToOneRelation, + modelClass: Step, + join: { + from: 'execution_steps.step_id', + to: 'steps.id', + }, + }, + }); + + get isFailed() { + return this.status === 'failure'; + } + + async isSucceededNonTestRun() { + const execution = await this.$relatedQuery('execution'); + return !execution.testRun && !this.isFailed; + } + + async updateUsageData() { + const execution = await this.$relatedQuery('execution'); + + const flow = await execution.$relatedQuery('flow'); + const user = await flow.$relatedQuery('user'); + const usageData = await user.$relatedQuery('currentUsageData'); + + await usageData.increaseConsumedTaskCountByOne(); + } + + async increaseUsageCount() { + if (appConfig.isCloud && this.isSucceededNonTestRun()) { + await this.updateUsageData(); + } + } + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + Telemetry.executionStepCreated(this); + await this.increaseUsageCount(); + } +} + +export default ExecutionStep; diff --git a/packages/backend/src/models/execution-step.test.js b/packages/backend/src/models/execution-step.test.js new file mode 100644 index 0000000..3148d6d --- /dev/null +++ b/packages/backend/src/models/execution-step.test.js @@ -0,0 +1,152 @@ +import { vi, describe, it, expect } from 'vitest'; +import Execution from './execution'; +import ExecutionStep from './execution-step'; +import Step from './step'; +import Base from './base'; +import UsageData from './usage-data.ee'; +import Telemetry from '../helpers/telemetry'; +import appConfig from '../config/app'; +import { createExecution } from '../../test/factories/execution'; +import { createExecutionStep } from '../../test/factories/execution-step'; + +describe('ExecutionStep model', () => { + it('tableName should return correct name', () => { + expect(ExecutionStep.tableName).toBe('execution_steps'); + }); + + it('jsonSchema should have correct validations', () => { + expect(ExecutionStep.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = ExecutionStep.relationMappings(); + + const expectedRelations = { + execution: { + relation: Base.BelongsToOneRelation, + modelClass: Execution, + join: { + from: 'execution_steps.execution_id', + to: 'executions.id', + }, + }, + step: { + relation: Base.BelongsToOneRelation, + modelClass: Step, + join: { + from: 'execution_steps.step_id', + to: 'steps.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + describe('isFailed', () => { + it('should return true if status is failure', async () => { + const executionStep = new ExecutionStep(); + executionStep.status = 'failure'; + + expect(executionStep.isFailed).toBe(true); + }); + + it('should return false if status is not failure', async () => { + const executionStep = new ExecutionStep(); + executionStep.status = 'success'; + + expect(executionStep.isFailed).toBe(false); + }); + }); + + describe('isSucceededNonTestRun', () => { + it('should return false if it has a test run execution', async () => { + const execution = await createExecution({ + testRun: true, + }); + + const executionStep = await createExecutionStep({ + executionId: execution.id, + }); + + expect(await executionStep.isSucceededNonTestRun()).toBe(false); + }); + + it('should return false if it has a failure status', async () => { + const executionStep = await createExecutionStep({ + status: 'failure', + }); + + expect(await executionStep.isSucceededNonTestRun()).toBe(false); + }); + + it('should return true if it has a succeeded non test run', async () => { + const executionStep = await createExecutionStep({ + status: 'success', + }); + + expect(await executionStep.isSucceededNonTestRun()).toBe(true); + }); + }); + + describe('updateUsageData', () => { + it('should call usageData.increaseConsumedTaskCountByOne', async () => { + const executionStep = await createExecutionStep(); + + const increaseConsumedTaskCountByOneSpy = vi.spyOn( + UsageData.prototype, + 'increaseConsumedTaskCountByOne' + ); + + await executionStep.updateUsageData(); + + expect(increaseConsumedTaskCountByOneSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('increaseUsageCount', () => { + it('should call updateUsageData for cloud and succeeded non test run', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + vi.spyOn( + ExecutionStep.prototype, + 'isSucceededNonTestRun' + ).mockReturnValue(true); + + const executionStep = await createExecutionStep(); + + const updateUsageDataSpy = vi.spyOn( + ExecutionStep.prototype, + 'updateUsageData' + ); + + await executionStep.increaseUsageCount(); + + expect(updateUsageDataSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('$afterInsert', () => { + it('should call Telemetry.executionStepCreated', async () => { + const telemetryExecutionStepCreatedSpy = vi + .spyOn(Telemetry, 'executionStepCreated') + .mockImplementation(() => {}); + + const executionStep = await createExecutionStep(); + + expect(telemetryExecutionStepCreatedSpy).toHaveBeenCalledWith( + executionStep + ); + }); + + it('should call increaseUsageCount', async () => { + const increaseUsageCountSpy = vi.spyOn( + ExecutionStep.prototype, + 'increaseUsageCount' + ); + + await createExecutionStep(); + + expect(increaseUsageCountSpy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/backend/src/models/execution.js b/packages/backend/src/models/execution.js new file mode 100644 index 0000000..9b21970 --- /dev/null +++ b/packages/backend/src/models/execution.js @@ -0,0 +1,48 @@ +import Base from './base.js'; +import Flow from './flow.js'; +import ExecutionStep from './execution-step.js'; +import Telemetry from '../helpers/telemetry/index.js'; + +class Execution extends Base { + static tableName = 'executions'; + + static jsonSchema = { + type: 'object', + + properties: { + id: { type: 'string', format: 'uuid' }, + flowId: { type: 'string', format: 'uuid' }, + testRun: { type: 'boolean', default: false }, + internalId: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + flow: { + relation: Base.BelongsToOneRelation, + modelClass: Flow, + join: { + from: 'executions.flow_id', + to: 'flows.id', + }, + }, + executionSteps: { + relation: Base.HasManyRelation, + modelClass: ExecutionStep, + join: { + from: 'executions.id', + to: 'execution_steps.execution_id', + }, + }, + }); + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + Telemetry.executionCreated(this); + } +} + +export default Execution; diff --git a/packages/backend/src/models/execution.test.js b/packages/backend/src/models/execution.test.js new file mode 100644 index 0000000..8591744 --- /dev/null +++ b/packages/backend/src/models/execution.test.js @@ -0,0 +1,52 @@ +import { vi, describe, it, expect } from 'vitest'; +import Execution from './execution'; +import ExecutionStep from './execution-step'; +import Flow from './flow'; +import Base from './base'; +import Telemetry from '../helpers/telemetry/index'; +import { createExecution } from '../../test/factories/execution'; + +describe('Execution model', () => { + it('tableName should return correct name', () => { + expect(Execution.tableName).toBe('executions'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Execution.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = Execution.relationMappings(); + + const expectedRelations = { + executionSteps: { + join: { + from: 'executions.id', + to: 'execution_steps.execution_id', + }, + modelClass: ExecutionStep, + relation: Base.HasManyRelation, + }, + flow: { + join: { + from: 'executions.flow_id', + to: 'flows.id', + }, + modelClass: Flow, + relation: Base.BelongsToOneRelation, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('$afterInsert should call Telemetry.executionCreated', async () => { + const telemetryExecutionCreatedSpy = vi + .spyOn(Telemetry, 'executionCreated') + .mockImplementation(() => {}); + + const execution = await createExecution(); + + expect(telemetryExecutionCreatedSpy).toHaveBeenCalledWith(execution); + }); +}); diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js new file mode 100644 index 0000000..750208a --- /dev/null +++ b/packages/backend/src/models/flow.js @@ -0,0 +1,484 @@ +import { ValidationError } from 'objection'; +import Base from './base.js'; +import Step from './step.js'; +import User from './user.js'; +import Folder from './folder.js'; +import Execution from './execution.js'; +import ExecutionStep from './execution-step.js'; +import globalVariable from '../helpers/global-variable.js'; +import logger from '../helpers/logger.js'; +import Telemetry from '../helpers/telemetry/index.js'; +import exportFlow from '../helpers/export-flow.js'; +import flowQueue from '../queues/flow.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +const JOB_NAME = 'flow'; +const EVERY_15_MINUTES_CRON = '*/15 * * * *'; + +class Flow extends Base { + static tableName = 'flows'; + + static jsonSchema = { + type: 'object', + required: ['name'], + + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + userId: { type: 'string', format: 'uuid' }, + folderId: { type: ['string', 'null'], format: 'uuid' }, + remoteWebhookId: { type: 'string' }, + active: { type: 'boolean' }, + publishedAt: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + steps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'flows.id', + to: 'steps.flow_id', + }, + filter(builder) { + builder.orderBy('position', 'asc'); + }, + }, + triggerStep: { + relation: Base.HasOneRelation, + modelClass: Step, + join: { + from: 'flows.id', + to: 'steps.flow_id', + }, + filter(builder) { + builder.where('type', 'trigger').limit(1).first(); + }, + }, + executions: { + relation: Base.HasManyRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + }, + lastExecution: { + relation: Base.HasOneRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + filter(builder) { + builder.orderBy('created_at', 'desc').limit(1).first(); + }, + }, + user: { + relation: Base.HasOneRelation, + modelClass: User, + join: { + from: 'flows.user_id', + to: 'users.id', + }, + }, + folder: { + relation: Base.HasOneRelation, + modelClass: Folder, + join: { + from: 'flows.folder_id', + to: 'folders.id', + }, + }, + }); + + static async populateStatusProperty(flows) { + const referenceFlow = flows[0]; + + if (referenceFlow) { + const shouldBePaused = await referenceFlow.isPaused(); + + for (const flow of flows) { + if (!flow.active) { + flow.status = 'draft'; + } else if (flow.active && shouldBePaused) { + flow.status = 'paused'; + } else { + flow.status = 'published'; + } + } + } + } + + static async afterFind(args) { + await this.populateStatusProperty(args.result); + } + + async lastInternalId() { + const lastExecution = await this.$relatedQuery('lastExecution'); + + return lastExecution ? lastExecution.internalId : null; + } + + async lastInternalIds(itemCount = 50) { + const lastExecutions = await this.$relatedQuery('executions') + .select('internal_id') + .orderBy('created_at', 'desc') + .limit(itemCount); + + return lastExecutions.map((execution) => execution.internalId); + } + + static get IncompleteStepsError() { + return new ValidationError({ + data: { + flow: [ + { + message: + 'All steps should be completed before updating flow status!', + }, + ], + }, + type: 'incompleteStepsError', + }); + } + + async createInitialSteps() { + await Step.query().insert({ + flowId: this.id, + type: 'trigger', + position: 1, + }); + + await Step.query().insert({ + flowId: this.id, + type: 'action', + position: 2, + }); + } + + async getStepById(stepId) { + return await this.$relatedQuery('steps').findById(stepId).throwIfNotFound(); + } + + async insertActionStepAtPosition(position) { + return await this.$relatedQuery('steps').insertAndFetch({ + type: 'action', + position, + }); + } + + async getStepsAfterPosition(position) { + return await this.$relatedQuery('steps').where('position', '>', position); + } + + async updateStepPositionsFrom(startPosition, steps) { + const stepPositionUpdates = steps.map(async (step, index) => { + return await step.$query().patch({ + position: startPosition + index, + }); + }); + + return await Promise.all(stepPositionUpdates); + } + + async createStepAfter(previousStepId) { + const previousStep = await this.getStepById(previousStepId); + + const nextSteps = await this.getStepsAfterPosition(previousStep.position); + + const createdStep = await this.insertActionStepAtPosition( + previousStep.position + 1 + ); + + await this.updateStepPositionsFrom(createdStep.position + 1, nextSteps); + + return createdStep; + } + + async unregisterWebhook() { + const triggerStep = await this.getTriggerStep(); + const trigger = await triggerStep?.getTriggerCommand(); + + if (trigger?.type === 'webhook' && trigger.unregisterHook) { + const $ = await globalVariable({ + flow: this, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + }); + + try { + await trigger.unregisterHook($); + } catch (error) { + // suppress error as the remote resource might have been already deleted + logger.debug( + `Failed to unregister webhook for flow ${this.id}: ${error.message}` + ); + } + } + } + + async deleteExecutionSteps() { + const executionIds = ( + await this.$relatedQuery('executions').select('executions.id') + ).map((execution) => execution.id); + + return await ExecutionStep.query() + .delete() + .whereIn('execution_id', executionIds); + } + + async deleteExecutions() { + return await this.$relatedQuery('executions').delete(); + } + + async deleteSteps() { + return await this.$relatedQuery('steps').delete(); + } + + async delete() { + await this.unregisterWebhook(); + + await this.deleteExecutionSteps(); + await this.deleteExecutions(); + await this.deleteSteps(); + + await this.$query().delete(); + } + + async duplicateFor(user) { + const steps = await this.$relatedQuery('steps').orderBy( + 'steps.position', + 'asc' + ); + + const duplicatedFlow = await user.$relatedQuery('flows').insertAndFetch({ + name: `Copy of ${this.name}`, + active: false, + }); + + const updateStepId = (value, newStepIds) => { + let newValue = value; + + const stepIdEntries = Object.entries(newStepIds); + for (const stepIdEntry of stepIdEntries) { + const [oldStepId, newStepId] = stepIdEntry; + + const partialOldVariable = `{{step.${oldStepId}.`; + const partialNewVariable = `{{step.${newStepId}.`; + + newValue = newValue.replaceAll(partialOldVariable, partialNewVariable); + } + + return newValue; + }; + + const updateStepVariables = (parameters, newStepIds) => { + const entries = Object.entries(parameters); + + return entries.reduce((result, [key, value]) => { + if (typeof value === 'string') { + return { + ...result, + [key]: updateStepId(value, newStepIds), + }; + } + + if (Array.isArray(value)) { + return { + ...result, + [key]: value.map((item) => updateStepVariables(item, newStepIds)), + }; + } + + return { + ...result, + [key]: value, + }; + }, {}); + }; + + const newStepIds = {}; + for (const step of steps) { + const duplicatedStep = await duplicatedFlow + .$relatedQuery('steps') + .insert({ + key: step.key, + appKey: step.appKey, + type: step.type, + connectionId: step.connectionId, + position: step.position, + parameters: updateStepVariables(step.parameters, newStepIds), + }); + + if (duplicatedStep.isTrigger) { + await duplicatedStep.updateWebhookUrl(); + } + + newStepIds[step.id] = duplicatedStep.id; + } + + const duplicatedFlowWithSteps = duplicatedFlow + .$query() + .withGraphJoined({ steps: true }) + .orderBy('steps.position', 'asc') + .throwIfNotFound(); + + return duplicatedFlowWithSteps; + } + + async getTriggerStep() { + return await this.$relatedQuery('steps').findOne({ + type: 'trigger', + }); + } + + async isPaused() { + const user = await this.$relatedQuery('user').withSoftDeleted(); + const allowedToRunFlows = await user.isAllowedToRunFlows(); + return allowedToRunFlows ? false : true; + } + + async updateFolder(folderId) { + const user = await this.$relatedQuery('user'); + + const folder = await user + .$relatedQuery('folders') + .findOne({ + id: folderId, + }) + .throwIfNotFound(); + + await this.$query().patch({ + folderId: folder.id, + }); + + return this.$query().withGraphFetched('folder'); + } + + async updateStatus(newActiveValue) { + if (this.active === newActiveValue) { + return this; + } + + const triggerStep = await this.getTriggerStep(); + + if (triggerStep.status === 'incomplete') { + throw Flow.IncompleteStepsError; + } + + const trigger = await triggerStep.getTriggerCommand(); + const interval = trigger.getInterval?.(triggerStep.parameters); + const repeatOptions = { + pattern: interval || EVERY_15_MINUTES_CRON, + }; + + if (trigger.type === 'webhook') { + const $ = await globalVariable({ + flow: this, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + testRun: false, + }); + + if (newActiveValue && trigger.registerHook) { + await trigger.registerHook($); + } else if (!newActiveValue && trigger.unregisterHook) { + await trigger.unregisterHook($); + } + } else { + if (newActiveValue) { + await this.$query().patchAndFetch({ + publishedAt: new Date().toISOString(), + }); + + const jobName = `${JOB_NAME}-${this.id}`; + + await flowQueue.add( + jobName, + { flowId: this.id }, + { + repeat: repeatOptions, + jobId: this.id, + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + } + ); + } else { + const repeatableJobs = await flowQueue.getRepeatableJobs(); + const job = repeatableJobs.find((job) => job.id === this.id); + + await flowQueue.removeRepeatableByKey(job.key); + } + } + + return await this.$query().withGraphFetched('steps').patchAndFetch({ + active: newActiveValue, + }); + } + + async throwIfHavingIncompleteSteps() { + const incompleteStep = await this.$relatedQuery('steps').findOne({ + status: 'incomplete', + }); + + if (incompleteStep) { + throw Flow.IncompleteStepsError; + } + } + + async throwIfHavingLessThanTwoSteps() { + const allSteps = await this.$relatedQuery('steps'); + + if (allSteps.length < 2) { + throw new ValidationError({ + data: { + flow: [ + { + message: + 'There should be at least one trigger and one action steps in the flow!', + }, + ], + }, + type: 'insufficientStepsError', + }); + } + } + + async export() { + return await exportFlow(this); + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + + if (this.active) { + await opt.old.throwIfHavingIncompleteSteps(); + + await opt.old.throwIfHavingLessThanTwoSteps(); + } + } + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + + Telemetry.flowCreated(this); + } + + async $afterUpdate(opt, queryContext) { + await super.$afterUpdate(opt, queryContext); + + Telemetry.flowUpdated(this); + } +} + +export default Flow; diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js new file mode 100644 index 0000000..dc9049b --- /dev/null +++ b/packages/backend/src/models/flow.test.js @@ -0,0 +1,665 @@ +import { describe, it, expect, vi } from 'vitest'; +import Flow from './flow.js'; +import User from './user.js'; +import Base from './base.js'; +import Step from './step.js'; +import Folder from './folder.js'; +import Execution from './execution.js'; +import Telemetry from '../helpers/telemetry/index.js'; +import * as globalVariableModule from '../helpers/global-variable.js'; +import { createFlow } from '../../test/factories/flow.js'; +import { createUser } from '../../test/factories/user.js'; +import { createFolder } from '../../test/factories/folder.js'; +import { createStep } from '../../test/factories/step.js'; +import { createExecution } from '../../test/factories/execution.js'; +import { createExecutionStep } from '../../test/factories/execution-step.js'; +import * as exportFlow from '../helpers/export-flow.js'; + +describe('Flow model', () => { + it('tableName should return correct name', () => { + expect(Flow.tableName).toBe('flows'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Flow.jsonSchema).toMatchSnapshot(); + }); + + describe('relationMappings', () => { + it('should return correct associations', () => { + const relationMappings = Flow.relationMappings(); + + const expectedRelations = { + steps: { + relation: Base.HasManyRelation, + modelClass: Step, + join: { + from: 'flows.id', + to: 'steps.flow_id', + }, + filter: expect.any(Function), + }, + triggerStep: { + relation: Base.HasOneRelation, + modelClass: Step, + join: { + from: 'flows.id', + to: 'steps.flow_id', + }, + filter: expect.any(Function), + }, + executions: { + relation: Base.HasManyRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + }, + lastExecution: { + relation: Base.HasOneRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + filter: expect.any(Function), + }, + user: { + relation: Base.HasOneRelation, + modelClass: User, + join: { + from: 'flows.user_id', + to: 'users.id', + }, + }, + folder: { + relation: Base.HasOneRelation, + modelClass: Folder, + join: { + from: 'flows.folder_id', + to: 'folders.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('steps should return the steps', () => { + const relations = Flow.relationMappings(); + const orderBySpy = vi.fn(); + + relations.steps.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('position', 'asc'); + }); + + it('triggerStep should return the trigger step', () => { + const relations = Flow.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const whereSpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.triggerStep.filter({ where: whereSpy }); + + expect(whereSpy).toHaveBeenCalledWith('type', 'trigger'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + + it('lastExecution should return the last execution', () => { + const relations = Flow.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const orderBySpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.lastExecution.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('populateStatusProperty', () => { + it('should assign "draft" to status property when a flow is not active', async () => { + const referenceFlow = await createFlow({ active: false }); + + const flows = [referenceFlow]; + + vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(); + + await Flow.populateStatusProperty(flows); + + expect(referenceFlow.status).toBe('draft'); + }); + + it('should assign "paused" to status property when a flow is active, but should be paused', async () => { + const referenceFlow = await createFlow({ active: true }); + + const flows = [referenceFlow]; + + vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(true); + + await Flow.populateStatusProperty(flows); + + expect(referenceFlow.status).toBe('paused'); + }); + + it('should assign "published" to status property when a flow is active', async () => { + const referenceFlow = await createFlow({ active: true }); + + const flows = [referenceFlow]; + + vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(false); + + await Flow.populateStatusProperty(flows); + + expect(referenceFlow.status).toBe('published'); + }); + }); + + it('afterFind should call Flow.populateStatusProperty', async () => { + const populateStatusPropertySpy = vi + .spyOn(Flow, 'populateStatusProperty') + .mockImplementation(() => {}); + + await createFlow(); + + expect(populateStatusPropertySpy).toHaveBeenCalledOnce(); + }); + + describe('lastInternalId', () => { + it('should return internal ID of last execution when exists', async () => { + const flow = await createFlow(); + + await createExecution({ flowId: flow.id }); + await createExecution({ flowId: flow.id }); + const lastExecution = await createExecution({ flowId: flow.id }); + + expect(await flow.lastInternalId()).toBe(lastExecution.internalId); + }); + + it('should return null when no flow execution exists', async () => { + const flow = await createFlow(); + + expect(await flow.lastInternalId()).toBe(null); + }); + }); + + describe('lastInternalIds', () => { + it('should return last internal IDs', async () => { + const flow = await createFlow(); + + const internalIds = [ + await createExecution({ flowId: flow.id }), + await createExecution({ flowId: flow.id }), + await createExecution({ flowId: flow.id }), + ].map((execution) => execution.internalId); + + expect(await flow.lastInternalIds()).toStrictEqual(internalIds); + }); + + it('should return last 50 internal IDs by default', async () => { + const flow = new Flow(); + + const limitSpy = vi.fn().mockResolvedValue([]); + + vi.spyOn(flow, '$relatedQuery').mockReturnValue({ + select: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: limitSpy, + }); + + await flow.lastInternalIds(); + + expect(limitSpy).toHaveBeenCalledWith(50); + }); + }); + + it('IncompleteStepsError should return validation error for incomplete steps', () => { + expect(() => { + throw Flow.IncompleteStepsError; + }).toThrowError( + 'flow: All steps should be completed before updating flow status!' + ); + }); + + it('createInitialSteps should create one trigger and one action step', async () => { + const flow = await createFlow(); + await flow.createInitialSteps(); + const steps = await flow.$relatedQuery('steps'); + + expect(steps.length).toBe(2); + + expect(steps[0]).toMatchObject({ + flowId: flow.id, + type: 'trigger', + position: 1, + }); + + expect(steps[1]).toMatchObject({ + flowId: flow.id, + type: 'action', + position: 2, + }); + }); + + it('getStepById should return the step with the given ID from the flow', async () => { + const flow = await createFlow(); + + const step = await createStep({ flowId: flow.id }); + + expect(await flow.getStepById(step.id)).toStrictEqual(step); + }); + + it('insertActionStepAtPosition should insert action step at given position', async () => { + const flow = await createFlow(); + + await flow.createInitialSteps(); + + const createdStep = await flow.insertActionStepAtPosition(2); + + expect(createdStep).toMatchObject({ + type: 'action', + position: 2, + }); + }); + + it('getStepsAfterPosition should return steps after the given position', async () => { + const flow = await createFlow(); + + await flow.createInitialSteps(); + + await createStep({ flowId: flow.id }); + + expect(await flow.getStepsAfterPosition(1)).toMatchObject([ + { position: 2 }, + { position: 3 }, + ]); + }); + + it('updateStepPositionsFrom', async () => { + const flow = await createFlow(); + + await createStep({ type: 'trigger', flowId: flow.id, position: 6 }); + await createStep({ type: 'action', flowId: flow.id, position: 8 }); + await createStep({ type: 'action', flowId: flow.id, position: 10 }); + + await flow.updateStepPositionsFrom(2, await flow.$relatedQuery('steps')); + + expect(await flow.$relatedQuery('steps')).toMatchObject([ + { position: 2, type: 'trigger' }, + { position: 3, type: 'action' }, + { position: 4, type: 'action' }, + ]); + }); + + it('createStepAfter should create an action step after given step ID', async () => { + const flow = await createFlow(); + + const triggerStep = await createStep({ type: 'trigger', flowId: flow.id }); + const actionStep = await createStep({ type: 'action', flowId: flow.id }); + + const createdStep = await flow.createStepAfter(triggerStep.id); + + const refetchedActionStep = await actionStep.$query(); + + expect(createdStep).toMatchObject({ type: 'action', position: 2 }); + expect(refetchedActionStep.position).toBe(3); + }); + + describe('unregisterWebhook', () => { + it('should unregister webhook on remote when supported', async () => { + const flow = await createFlow(); + const triggerStep = await createStep({ + flowId: flow.id, + appKey: 'typeform', + key: 'new-entry', + type: 'trigger', + }); + + const unregisterHookSpy = vi.fn().mockResolvedValue(); + + vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({ + type: 'webhook', + unregisterHook: unregisterHookSpy, + }); + + const globalVariableSpy = vi + .spyOn(globalVariableModule, 'default') + .mockResolvedValue('global-variable'); + + await flow.unregisterWebhook(); + + expect(unregisterHookSpy).toHaveBeenCalledWith('global-variable'); + expect(globalVariableSpy).toHaveBeenCalledWith({ + flow, + step: triggerStep, + connection: undefined, + app: await triggerStep.getApp(), + }); + }); + + it('should silently fail when unregistration fails', async () => { + const flow = await createFlow(); + await createStep({ + flowId: flow.id, + appKey: 'typeform', + key: 'new-entry', + type: 'trigger', + }); + + const unregisterHookSpy = vi.fn().mockRejectedValue(new Error()); + + vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({ + type: 'webhook', + unregisterHook: unregisterHookSpy, + }); + + expect(await flow.unregisterWebhook()).toBe(undefined); + expect(unregisterHookSpy).toHaveBeenCalledOnce(); + }); + + it('should do nothing when trigger step is not webhook', async () => { + const flow = await createFlow(); + await createStep({ + flowId: flow.id, + type: 'trigger', + }); + + const unregisterHookSpy = vi.fn().mockRejectedValue(new Error()); + + expect(await flow.unregisterWebhook()).toBe(undefined); + expect(unregisterHookSpy).not.toHaveBeenCalled(); + }); + }); + + it('deleteExecutionSteps should delete related execution steps', async () => { + const flow = await createFlow(); + const execution = await createExecution({ flowId: flow.id }); + const firstExecutionStep = await createExecutionStep({ + executionId: execution.id, + }); + const secondExecutionStep = await createExecutionStep({ + executionId: execution.id, + }); + + await flow.deleteExecutionSteps(); + + expect(await firstExecutionStep.$query()).toBe(undefined); + expect(await secondExecutionStep.$query()).toBe(undefined); + }); + + it('deleteExecutions should delete related executions', async () => { + const flow = await createFlow(); + const firstExecution = await createExecution({ flowId: flow.id }); + const secondExecution = await createExecution({ flowId: flow.id }); + + await flow.deleteExecutions(); + + expect(await firstExecution.$query()).toBe(undefined); + expect(await secondExecution.$query()).toBe(undefined); + }); + + it('deleteSteps should delete related steps', async () => { + const flow = await createFlow(); + await flow.createInitialSteps(); + await flow.deleteSteps(); + + expect(await flow.$relatedQuery('steps')).toStrictEqual([]); + }); + + it('delete should delete the flow with its relations', async () => { + const flow = await createFlow(); + + const unregisterWebhookSpy = vi + .spyOn(flow, 'unregisterWebhook') + .mockResolvedValue(); + const deleteExecutionStepsSpy = vi + .spyOn(flow, 'deleteExecutionSteps') + .mockResolvedValue(); + const deleteExecutionsSpy = vi + .spyOn(flow, 'deleteExecutions') + .mockResolvedValue(); + const deleteStepsSpy = vi.spyOn(flow, 'deleteSteps').mockResolvedValue(); + + await flow.delete(); + + expect(unregisterWebhookSpy).toHaveBeenCalledOnce(); + expect(deleteExecutionStepsSpy).toHaveBeenCalledOnce(); + expect(deleteExecutionsSpy).toHaveBeenCalledOnce(); + expect(deleteStepsSpy).toHaveBeenCalledOnce(); + expect(await flow.$query()).toBe(undefined); + }); + + it.todo('duplicateFor'); + + it('getTriggerStep', async () => { + const flow = await createFlow(); + const triggerStep = await createStep({ flowId: flow.id, type: 'trigger' }); + + await createStep({ flowId: flow.id, type: 'action' }); + + expect(await flow.getTriggerStep()).toStrictEqual(triggerStep); + }); + + describe('isPaused', () => { + it('should return true when user.isAllowedToRunFlows returns false', async () => { + const flow = await createFlow(); + + const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(false); + vi.spyOn(flow, '$relatedQuery').mockReturnValue({ + withSoftDeleted: vi.fn().mockReturnThis(), + isAllowedToRunFlows: isAllowedToRunFlowsSpy, + }); + + expect(await flow.isPaused()).toBe(true); + expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce(); + }); + + it('should return false when user.isAllowedToRunFlows returns true', async () => { + const flow = await createFlow(); + + const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(true); + vi.spyOn(flow, '$relatedQuery').mockReturnValue({ + withSoftDeleted: vi.fn().mockReturnThis(), + isAllowedToRunFlows: isAllowedToRunFlowsSpy, + }); + + expect(await flow.isPaused()).toBe(false); + expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('updateFolder', () => { + it('should throw an error if the folder does not exist', async () => { + const user = await createUser(); + const flow = await createFlow({ userId: user.id }); + const nonExistentFolderId = 'non-existent-folder-id'; + + await expect(flow.updateFolder(nonExistentFolderId)).rejects.toThrow(); + }); + + it('should return the flow with the updated folder', async () => { + const user = await createUser(); + const flow = await createFlow({ userId: user.id }); + const folder = await createFolder({ userId: user.id }); + + const updatedFlow = await flow.updateFolder(folder.id); + + expect(updatedFlow.folder.id).toBe(folder.id); + expect(updatedFlow.folder.name).toBe(folder.name); + }); + }); + + describe('throwIfHavingIncompleteSteps', () => { + it('should throw validation error with incomplete steps', async () => { + const flow = await createFlow(); + + await flow.createInitialSteps(); + + await expect(() => + flow.throwIfHavingIncompleteSteps() + ).rejects.toThrowError( + 'flow: All steps should be completed before updating flow status!' + ); + }); + + it('should return undefined when all steps are completed', async () => { + const flow = await createFlow(); + + await createStep({ + flowId: flow.id, + status: 'completed', + type: 'trigger', + }); + + await createStep({ + flowId: flow.id, + status: 'completed', + type: 'action', + }); + + expect(await flow.throwIfHavingIncompleteSteps()).toBe(undefined); + }); + }); + + describe('export', () => { + it('should return exportedFlow', async () => { + const flow = await createFlow(); + + const exportedFlowAsString = { + name: 'My Flow Name', + }; + + vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); + + expect(await flow.export()).toStrictEqual({ + name: 'My Flow Name', + }); + }); + }); + + describe('throwIfHavingLessThanTwoSteps', () => { + it('should throw validation error with less than two steps', async () => { + const flow = await createFlow(); + + await expect(() => + flow.throwIfHavingLessThanTwoSteps() + ).rejects.toThrowError( + 'flow: There should be at least one trigger and one action steps in the flow!' + ); + }); + + it('should return undefined when there are at least two steps', async () => { + const flow = await createFlow(); + + await createStep({ + flowId: flow.id, + type: 'trigger', + }); + + await createStep({ + flowId: flow.id, + type: 'action', + }); + + expect(await flow.throwIfHavingLessThanTwoSteps()).toBe(undefined); + }); + }); + + describe('$beforeUpdate', () => { + it('should invoke throwIfHavingIncompleteSteps when flow is becoming active', async () => { + const flow = await createFlow({ active: false }); + + const throwIfHavingIncompleteStepsSpy = vi + .spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps') + .mockImplementation(() => {}); + + const throwIfHavingLessThanTwoStepsSpy = vi + .spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps') + .mockImplementation(() => {}); + + await flow.$query().patch({ active: true }); + + expect(throwIfHavingIncompleteStepsSpy).toHaveBeenCalledOnce(); + expect(throwIfHavingLessThanTwoStepsSpy).toHaveBeenCalledOnce(); + }); + + it('should invoke throwIfHavingIncompleteSteps when flow is not becoming active', async () => { + const flow = await createFlow({ active: true }); + + const throwIfHavingIncompleteStepsSpy = vi + .spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps') + .mockImplementation(() => {}); + + const throwIfHavingLessThanTwoStepsSpy = vi + .spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps') + .mockImplementation(() => {}); + + await flow.$query().patch({}); + + expect(throwIfHavingIncompleteStepsSpy).not.toHaveBeenCalledOnce(); + expect(throwIfHavingLessThanTwoStepsSpy).not.toHaveBeenCalledOnce(); + }); + }); + + describe('$afterInsert', () => { + it('should call super.$afterInsert', async () => { + const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert'); + + await createFlow(); + + expect(superAfterInsertSpy).toHaveBeenCalled(); + }); + + it('should call Telemetry.flowCreated', async () => { + const telemetryFlowCreatedSpy = vi + .spyOn(Telemetry, 'flowCreated') + .mockImplementation(() => {}); + + const flow = await createFlow(); + + expect(telemetryFlowCreatedSpy).toHaveBeenCalledWith(flow); + }); + }); + + describe('$afterUpdate', () => { + it('should call super.$afterUpdate', async () => { + const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate'); + + const flow = await createFlow(); + + await flow.$query().patch({ active: false }); + + expect(superAfterUpdateSpy).toHaveBeenCalledOnce(); + }); + + it('$afterUpdate should call Telemetry.flowUpdated', async () => { + const telemetryFlowUpdatedSpy = vi + .spyOn(Telemetry, 'flowUpdated') + .mockImplementation(() => {}); + + const flow = await createFlow(); + + await flow.$query().patch({ active: false }); + + expect(telemetryFlowUpdatedSpy).toHaveBeenCalled({}); + }); + }); +}); diff --git a/packages/backend/src/models/folder.js b/packages/backend/src/models/folder.js new file mode 100644 index 0000000..96eec5e --- /dev/null +++ b/packages/backend/src/models/folder.js @@ -0,0 +1,30 @@ +import Base from './base.js'; +import User from './user.js'; + +class Folder extends Base { + static tableName = 'folders'; + + static jsonSchema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + userId: { type: 'string', format: 'uuid' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'folders.user_id', + to: 'users.id', + }, + }, + }); +} + +export default Folder; diff --git a/packages/backend/src/models/folder.test.js b/packages/backend/src/models/folder.test.js new file mode 100644 index 0000000..3fada77 --- /dev/null +++ b/packages/backend/src/models/folder.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import Folder from './folder'; +import User from './user'; +import Base from './base'; + +describe('Folder model', () => { + it('tableName should return correct name', () => { + expect(Folder.tableName).toBe('folders'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Folder.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = Folder.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'folders.user_id', + to: 'users.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); +}); diff --git a/packages/backend/src/models/identity.ee.js b/packages/backend/src/models/identity.ee.js new file mode 100644 index 0000000..09b3742 --- /dev/null +++ b/packages/backend/src/models/identity.ee.js @@ -0,0 +1,41 @@ +import Base from './base.js'; +import SamlAuthProvider from './saml-auth-provider.ee.js'; +import User from './user.js'; + +class Identity extends Base { + static tableName = 'identities'; + + static jsonSchema = { + type: 'object', + required: ['providerId', 'remoteId', 'userId', 'providerType'], + + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' }, + remoteId: { type: 'string', minLength: 1 }, + providerId: { type: 'string', format: 'uuid' }, + providerType: { type: 'string', enum: ['saml'] }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'users.id', + to: 'identities.user_id', + }, + }, + samlAuthProvider: { + relation: Base.BelongsToOneRelation, + modelClass: SamlAuthProvider, + join: { + from: 'saml_auth_providers.id', + to: 'identities.provider_id', + }, + }, + }); +} + +export default Identity; diff --git a/packages/backend/src/models/identity.ee.test.js b/packages/backend/src/models/identity.ee.test.js new file mode 100644 index 0000000..599a229 --- /dev/null +++ b/packages/backend/src/models/identity.ee.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import Identity from './identity.ee'; +import User from './user'; +import SamlAuthProvider from './saml-auth-provider.ee'; +import Base from './base'; + +describe('Identity model', () => { + it('tableName should return correct name', () => { + expect(Identity.tableName).toBe('identities'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Identity.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = Identity.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'users.id', + to: 'identities.user_id', + }, + }, + samlAuthProvider: { + relation: Base.BelongsToOneRelation, + modelClass: SamlAuthProvider, + join: { + from: 'saml_auth_providers.id', + to: 'identities.provider_id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); +}); diff --git a/packages/backend/src/models/oauth-client.js b/packages/backend/src/models/oauth-client.js new file mode 100644 index 0000000..d4c253a --- /dev/null +++ b/packages/backend/src/models/oauth-client.js @@ -0,0 +1,90 @@ +import AES from 'crypto-js/aes.js'; +import enc from 'crypto-js/enc-utf8.js'; +import appConfig from '../config/app.js'; +import Base from './base.js'; +import AppConfig from './app-config.js'; + +class OAuthClient extends Base { + static tableName = 'oauth_clients'; + + static jsonSchema = { + type: 'object', + required: ['name', 'appKey', 'formattedAuthDefaults'], + + properties: { + id: { type: 'string', format: 'uuid' }, + appKey: { type: 'string' }, + active: { type: 'boolean' }, + authDefaults: { type: ['string', 'null'] }, + formattedAuthDefaults: { type: 'object' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'oauth_clients.app_key', + to: 'app_configs.key', + }, + }, + }); + + encryptData() { + if (!this.eligibleForEncryption()) return; + + this.authDefaults = AES.encrypt( + JSON.stringify(this.formattedAuthDefaults), + appConfig.encryptionKey + ).toString(); + + delete this.formattedAuthDefaults; + } + + decryptData() { + if (!this.eligibleForDecryption()) return; + + this.formattedAuthDefaults = JSON.parse( + AES.decrypt(this.authDefaults, appConfig.encryptionKey).toString(enc) + ); + } + + eligibleForEncryption() { + return this.formattedAuthDefaults ? true : false; + } + + eligibleForDecryption() { + return this.authDefaults ? true : false; + } + + // TODO: Make another abstraction like beforeSave instead of using + // beforeInsert and beforeUpdate separately for the same operation. + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + + this.encryptData(); + } + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + + this.encryptData(); + } + + async $afterUpdate(opt, queryContext) { + await super.$afterUpdate(opt, queryContext); + } + + async $afterFind() { + this.decryptData(); + } +} + +export default OAuthClient; diff --git a/packages/backend/src/models/oauth-client.test.js b/packages/backend/src/models/oauth-client.test.js new file mode 100644 index 0000000..e1d1715 --- /dev/null +++ b/packages/backend/src/models/oauth-client.test.js @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from 'vitest'; +import AES from 'crypto-js/aes.js'; +import enc from 'crypto-js/enc-utf8.js'; + +import AppConfig from './app-config.js'; +import OAuthClient from './oauth-client.js'; +import Base from './base.js'; +import appConfig from '../config/app.js'; +import { createOAuthClient } from '../../test/factories/oauth-client.js'; + +describe('OAuthClient model', () => { + it('tableName should return correct name', () => { + expect(OAuthClient.tableName).toBe('oauth_clients'); + }); + + it('jsonSchema should have correct validations', () => { + expect(OAuthClient.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = OAuthClient.relationMappings(); + + const expectedRelations = { + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'oauth_clients.app_key', + to: 'app_configs.key', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + describe('encryptData', () => { + it('should return undefined if eligibleForEncryption is not true', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForEncryption').mockReturnValue( + false + ); + + const oauthClient = new OAuthClient(); + + expect(oauthClient.encryptData()).toBeUndefined(); + }); + + it('should encrypt formattedAuthDefaults and set it to authDefaults', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForEncryption').mockReturnValue( + true + ); + + const formattedAuthDefaults = { + key: 'value', + }; + + const oauthClient = new OAuthClient(); + oauthClient.formattedAuthDefaults = formattedAuthDefaults; + oauthClient.encryptData(); + + const expectedDecryptedValue = JSON.parse( + AES.decrypt(oauthClient.authDefaults, appConfig.encryptionKey).toString( + enc + ) + ); + + expect(formattedAuthDefaults).toStrictEqual(expectedDecryptedValue); + expect(oauthClient.authDefaults).not.toStrictEqual(formattedAuthDefaults); + }); + + it('should encrypt formattedAuthDefaults and remove formattedAuthDefaults', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForEncryption').mockReturnValue( + true + ); + + const formattedAuthDefaults = { + key: 'value', + }; + + const oauthClient = new OAuthClient(); + oauthClient.formattedAuthDefaults = formattedAuthDefaults; + oauthClient.encryptData(); + + expect(oauthClient.formattedAuthDefaults).not.toBeDefined(); + }); + }); + + describe('decryptData', () => { + it('should return undefined if eligibleForDecryption is not true', () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForDecryption').mockReturnValue( + false + ); + + const oauthClient = new OAuthClient(); + + expect(oauthClient.decryptData()).toBeUndefined(); + }); + + it('should decrypt authDefaults and set it to formattedAuthDefaults', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForDecryption').mockReturnValue( + true + ); + + const formattedAuthDefaults = { + key: 'value', + }; + + const authDefaults = AES.encrypt( + JSON.stringify(formattedAuthDefaults), + appConfig.encryptionKey + ).toString(); + + const oauthClient = new OAuthClient(); + oauthClient.authDefaults = authDefaults; + oauthClient.decryptData(); + + expect(oauthClient.formattedAuthDefaults).toStrictEqual( + formattedAuthDefaults + ); + expect(oauthClient.authDefaults).not.toStrictEqual(formattedAuthDefaults); + }); + }); + + describe('eligibleForEncryption', () => { + it('should return true when formattedAuthDefaults property exists', async () => { + const oauthClient = await createOAuthClient(); + + expect(oauthClient.eligibleForEncryption()).toBe(true); + }); + + it("should return false when formattedAuthDefaults property doesn't exist", async () => { + const oauthClient = await createOAuthClient(); + + delete oauthClient.formattedAuthDefaults; + + expect(oauthClient.eligibleForEncryption()).toBe(false); + }); + }); + + describe('eligibleForDecryption', () => { + it('should return true when authDefaults property exists', async () => { + const oauthClient = await createOAuthClient(); + + expect(oauthClient.eligibleForDecryption()).toBe(true); + }); + + it("should return false when authDefaults property doesn't exist", async () => { + const oauthClient = await createOAuthClient(); + + delete oauthClient.authDefaults; + + expect(oauthClient.eligibleForDecryption()).toBe(false); + }); + }); + + it('$beforeInsert should call OAuthClient.encryptData', async () => { + const oauthClientBeforeInsertSpy = vi.spyOn( + OAuthClient.prototype, + 'encryptData' + ); + + await createOAuthClient(); + + expect(oauthClientBeforeInsertSpy).toHaveBeenCalledOnce(); + }); + + it('$beforeUpdate should call OAuthClient.encryptData', async () => { + const oauthClient = await createOAuthClient(); + + const oauthClientBeforeUpdateSpy = vi.spyOn( + OAuthClient.prototype, + 'encryptData' + ); + + await oauthClient.$query().patchAndFetch({ name: 'sample' }); + + expect(oauthClientBeforeUpdateSpy).toHaveBeenCalledOnce(); + }); + + it('$afterFind should call OAuthClient.decryptData', async () => { + const oauthClient = await createOAuthClient(); + + const oauthClientAfterFindSpy = vi.spyOn( + OAuthClient.prototype, + 'decryptData' + ); + + await oauthClient.$query(); + + expect(oauthClientAfterFindSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/backend/src/models/permission.js b/packages/backend/src/models/permission.js new file mode 100644 index 0000000..a58aa53 --- /dev/null +++ b/packages/backend/src/models/permission.js @@ -0,0 +1,57 @@ +import Base from './base.js'; +import permissionCatalog from '../helpers/permission-catalog.ee.js'; + +class Permission extends Base { + static tableName = 'permissions'; + + static jsonSchema = { + type: 'object', + required: ['roleId', 'action', 'subject'], + + properties: { + id: { type: 'string', format: 'uuid' }, + roleId: { type: 'string', format: 'uuid' }, + action: { type: 'string', minLength: 1 }, + subject: { type: 'string', minLength: 1 }, + conditions: { type: 'array', items: { type: 'string' } }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static filter(permissions) { + const sanitizedPermissions = permissions.filter((permission) => { + const { action, subject, conditions } = permission; + + const relevantAction = this.findAction(action); + const validSubject = this.isSubjectValid(subject, relevantAction); + const validConditions = this.areConditionsValid(conditions); + + return relevantAction && validSubject && validConditions; + }); + + return sanitizedPermissions; + } + + static findAction(action) { + return permissionCatalog.actions.find( + (actionCatalogItem) => actionCatalogItem.key === action + ); + } + + static isSubjectValid(subject, action) { + return action && action.subjects.includes(subject); + } + + static areConditionsValid(conditions) { + return conditions.every((condition) => this.isConditionValid(condition)); + } + + static isConditionValid(condition) { + return !!permissionCatalog.conditions.find( + (conditionCatalogItem) => conditionCatalogItem.key === condition + ); + } +} + +export default Permission; diff --git a/packages/backend/src/models/permission.test.js b/packages/backend/src/models/permission.test.js new file mode 100644 index 0000000..c53b821 --- /dev/null +++ b/packages/backend/src/models/permission.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import Permission from './permission'; +import permissionCatalog from '../helpers/permission-catalog.ee.js'; + +describe('Permission model', () => { + it('tableName should return correct name', () => { + expect(Permission.tableName).toBe('permissions'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Permission.jsonSchema).toMatchSnapshot(); + }); + + it('filter should return only valid permissions based on permission catalog', () => { + const permissions = [ + { action: 'read', subject: 'Flow', conditions: ['isCreator'] }, + { action: 'delete', subject: 'Connection', conditions: [] }, + { action: 'publish', subject: 'Flow', conditions: ['isCreator'] }, + { action: 'update', subject: 'Execution', conditions: [] }, // Invalid subject + { action: 'read', subject: 'Execution', conditions: ['invalid'] }, // Invalid condition + { action: 'invalid', subject: 'Execution', conditions: [] }, // Invalid action + ]; + + const result = Permission.filter(permissions); + + expect(result).toStrictEqual([ + { action: 'read', subject: 'Flow', conditions: ['isCreator'] }, + { action: 'delete', subject: 'Connection', conditions: [] }, + { action: 'publish', subject: 'Flow', conditions: ['isCreator'] }, + ]); + }); + + describe('findAction', () => { + it('should return action from permission catalog', () => { + const action = Permission.findAction('create'); + expect(action.key).toStrictEqual('create'); + }); + + it('should return undefined for invalid actions', () => { + const invalidAction = Permission.findAction('invalidAction'); + expect(invalidAction).toBeUndefined(); + }); + }); + + describe('isSubjectValid', () => { + it('should return true for valid subjects', () => { + const validAction = permissionCatalog.actions.find( + (action) => action.key === 'create' + ); + + const validSubject = Permission.isSubjectValid('Connection', validAction); + expect(validSubject).toBe(true); + }); + + it('should return false for invalid subjects', () => { + const validAction = permissionCatalog.actions.find( + (action) => action.key === 'create' + ); + + const invalidSubject = Permission.isSubjectValid( + 'Execution', + validAction + ); + + expect(invalidSubject).toBe(false); + }); + }); + + describe('areConditionsValid', () => { + it('should return true for valid conditions', () => { + const validConditions = Permission.areConditionsValid(['isCreator']); + expect(validConditions).toBe(true); + }); + + it('should return false for invalid conditions', () => { + const invalidConditions = Permission.areConditionsValid([ + 'invalidCondition', + ]); + + expect(invalidConditions).toBe(false); + }); + }); + + describe('isConditionValid', () => { + it('should return true for valid conditions', () => { + const validCondition = Permission.isConditionValid('isCreator'); + expect(validCondition).toBe(true); + }); + + it('should return false for invalid conditions', () => { + const invalidCondition = Permission.isConditionValid('invalidCondition'); + expect(invalidCondition).toBe(false); + }); + }); +}); diff --git a/packages/backend/src/models/query-builder.js b/packages/backend/src/models/query-builder.js new file mode 100644 index 0000000..7dd62c6 --- /dev/null +++ b/packages/backend/src/models/query-builder.js @@ -0,0 +1,70 @@ +import { Model } from 'objection'; + +const DELETED_COLUMN_NAME = 'deleted_at'; + +const supportsSoftDeletion = (modelClass) => { + return modelClass.jsonSchema.properties.deletedAt; +}; + +const buildQueryBuidlerForClass = () => { + return (modelClass) => { + const qb = Model.QueryBuilder.forClass.call( + ExtendedQueryBuilder, + modelClass + ); + qb.onBuild((builder) => { + if ( + !builder.context().withSoftDeleted && + supportsSoftDeletion(qb.modelClass()) + ) { + builder.whereNull( + `${qb.modelClass().tableName}.${DELETED_COLUMN_NAME}` + ); + } + }); + return qb; + }; +}; + +class ExtendedQueryBuilder extends Model.QueryBuilder { + static forClass = buildQueryBuidlerForClass(); + + delete() { + if (supportsSoftDeletion(this.modelClass())) { + return this.patch({ + [DELETED_COLUMN_NAME]: new Date().toISOString(), + }); + } + + return super.delete(); + } + + hardDelete() { + return super.delete(); + } + + withSoftDeleted() { + this.context().withSoftDeleted = true; + return this; + } + + restore() { + return this.patch({ + [DELETED_COLUMN_NAME]: null, + }); + } + + async updateFirstOrInsert(data = {}) { + let firstRow = await this.first(); + + if (firstRow) { + return firstRow.$query().patchAndFetch(data); + } + + const newInstance = this.insertAndFetch(data); + + return newInstance; + } +} + +export default ExtendedQueryBuilder; diff --git a/packages/backend/src/models/role-mapping.ee.js b/packages/backend/src/models/role-mapping.ee.js new file mode 100644 index 0000000..0a5866e --- /dev/null +++ b/packages/backend/src/models/role-mapping.ee.js @@ -0,0 +1,31 @@ +import Base from './base.js'; +import SamlAuthProvider from './saml-auth-provider.ee.js'; + +class RoleMapping extends Base { + static tableName = 'role_mappings'; + + static jsonSchema = { + type: 'object', + required: ['samlAuthProviderId', 'roleId', 'remoteRoleName'], + + properties: { + id: { type: 'string', format: 'uuid' }, + samlAuthProviderId: { type: 'string', format: 'uuid' }, + roleId: { type: 'string', format: 'uuid' }, + remoteRoleName: { type: 'string', minLength: 1 }, + }, + }; + + static relationMappings = () => ({ + samlAuthProvider: { + relation: Base.BelongsToOneRelation, + modelClass: SamlAuthProvider, + join: { + from: 'role_mappings.saml_auth_provider_id', + to: 'saml_auth_providers.id', + }, + }, + }); +} + +export default RoleMapping; diff --git a/packages/backend/src/models/role-mapping.ee.test.js b/packages/backend/src/models/role-mapping.ee.test.js new file mode 100644 index 0000000..2540011 --- /dev/null +++ b/packages/backend/src/models/role-mapping.ee.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import RoleMapping from './role-mapping.ee'; +import SamlAuthProvider from './saml-auth-provider.ee'; +import Base from './base'; + +describe('RoleMapping model', () => { + it('tableName should return correct name', () => { + expect(RoleMapping.tableName).toBe('role_mappings'); + }); + + it('jsonSchema should have the correct schema', () => { + expect(RoleMapping.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = RoleMapping.relationMappings(); + + const expectedRelations = { + samlAuthProvider: { + relation: Base.BelongsToOneRelation, + modelClass: SamlAuthProvider, + join: { + from: 'role_mappings.saml_auth_provider_id', + to: 'saml_auth_providers.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); +}); diff --git a/packages/backend/src/models/role.js b/packages/backend/src/models/role.js new file mode 100644 index 0000000..9e0404a --- /dev/null +++ b/packages/backend/src/models/role.js @@ -0,0 +1,174 @@ +import { ValidationError } from 'objection'; +import Base from './base.js'; +import Permission from './permission.js'; +import User from './user.js'; +import SamlAuthProvider from './saml-auth-provider.ee.js'; +import NotAuthorizedError from '../errors/not-authorized.js'; + +class Role extends Base { + static tableName = 'roles'; + + static jsonSchema = { + type: 'object', + required: ['name'], + + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + description: { type: ['string', 'null'], maxLength: 255 }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + users: { + relation: Base.HasManyRelation, + modelClass: User, + join: { + from: 'roles.id', + to: 'users.role_id', + }, + }, + permissions: { + relation: Base.HasManyRelation, + modelClass: Permission, + join: { + from: 'roles.id', + to: 'permissions.role_id', + }, + }, + }); + + static get virtualAttributes() { + return ['isAdmin']; + } + + get isAdmin() { + return this.name === 'Admin'; + } + + static async findAdmin() { + return await this.query().findOne({ name: 'Admin' }); + } + + async preventAlteringAdmin() { + const currentRole = await Role.query().findById(this.id); + + if (currentRole.isAdmin) { + throw new NotAuthorizedError('The admin role cannot be altered!'); + } + } + + async deletePermissions() { + return await this.$relatedQuery('permissions').delete(); + } + + async createPermissions(permissions) { + if (permissions?.length) { + const validPermissions = Permission.filter(permissions).map( + (permission) => ({ + ...permission, + roleId: this.id, + }) + ); + + await Permission.query().insert(validPermissions); + } + } + + async updatePermissions(permissions) { + await this.deletePermissions(); + + await this.createPermissions(permissions); + } + + async updateWithPermissions(data) { + const { name, description, permissions } = data; + + await this.updatePermissions(permissions); + + await this.$query().patchAndFetch({ + id: this.id, + name, + description, + }); + + return await this.$query() + .leftJoinRelated({ + permissions: true, + }) + .withGraphFetched({ + permissions: true, + }); + } + + async deleteWithPermissions() { + await this.deletePermissions(); + + return await this.$query().delete(); + } + + async assertNoRoleUserExists() { + const userCount = await this.$relatedQuery('users').limit(1).resultSize(); + const hasUsers = userCount > 0; + + if (hasUsers) { + throw new ValidationError({ + data: { + role: [ + { + message: `All users must be migrated away from the "${this.name}" role.`, + }, + ], + }, + type: 'ValidationError', + }); + } + } + + async assertNoConfigurationUsage() { + const samlAuthProviderUsingDefaultRole = await SamlAuthProvider.query() + .where({ + default_role_id: this.id, + }) + .limit(1) + .first(); + + if (samlAuthProviderUsingDefaultRole) { + throw new ValidationError({ + data: { + samlAuthProvider: [ + { + message: + 'You need to change the default role in the SAML configuration before deleting this role.', + }, + ], + }, + type: 'ValidationError', + }); + } + } + + async assertRoleIsNotUsed() { + await this.assertNoRoleUserExists(); + + await this.assertNoConfigurationUsage(); + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + + await this.preventAlteringAdmin(); + } + + async $beforeDelete(queryContext) { + await super.$beforeDelete(queryContext); + + await this.preventAlteringAdmin(); + + await this.assertRoleIsNotUsed(); + } +} + +export default Role; diff --git a/packages/backend/src/models/role.test.js b/packages/backend/src/models/role.test.js new file mode 100644 index 0000000..780c8f0 --- /dev/null +++ b/packages/backend/src/models/role.test.js @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest'; +import Role from './role'; +import Base from './base.js'; +import Permission from './permission.js'; +import User from './user.js'; +import { createRole } from '../../test/factories/role.js'; +import { createPermission } from '../../test/factories/permission.js'; +import { createUser } from '../../test/factories/user.js'; +import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js'; + +describe('Role model', () => { + it('tableName should return correct name', () => { + expect(Role.tableName).toBe('roles'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Role.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappingsshould return correct associations', () => { + const relationMappings = Role.relationMappings(); + + const expectedRelations = { + users: { + relation: Base.HasManyRelation, + modelClass: User, + join: { + from: 'roles.id', + to: 'users.role_id', + }, + }, + permissions: { + relation: Base.HasManyRelation, + modelClass: Permission, + join: { + from: 'roles.id', + to: 'permissions.role_id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('virtualAttributes should return correct attributes', () => { + expect(Role.virtualAttributes).toStrictEqual(['isAdmin']); + }); + + describe('isAdmin', () => { + it('should return true for admin named role', () => { + const role = new Role(); + role.name = 'Admin'; + + expect(role.isAdmin).toBe(true); + }); + + it('should return false for not admin named roles', () => { + const role = new Role(); + role.name = 'User'; + + expect(role.isAdmin).toBe(false); + }); + }); + + it('findAdmin should return admin role', async () => { + const createdAdminRole = await createRole({ name: 'Admin' }); + + const adminRole = await Role.findAdmin(); + + expect(createdAdminRole).toStrictEqual(adminRole); + }); + + describe('preventAlteringAdmin', () => { + it('preventAlteringAdmin should throw an error when altering admin role', async () => { + const role = await createRole({ name: 'Admin' }); + + await expect(() => role.preventAlteringAdmin()).rejects.toThrowError( + 'The admin role cannot be altered!' + ); + }); + + it('preventAlteringAdmin should not throw an error when altering non-admin roles', async () => { + const role = await createRole({ name: 'User' }); + + expect(await role.preventAlteringAdmin()).toBe(undefined); + }); + }); + + it("deletePermissions should delete role's permissions", async () => { + const role = await createRole({ name: 'User' }); + await createPermission({ roleId: role.id }); + + await role.deletePermissions(); + + expect(await role.$relatedQuery('permissions')).toStrictEqual([]); + }); + + describe('createPermissions', () => { + it('should create permissions', async () => { + const role = await createRole({ name: 'User' }); + + await role.createPermissions([ + { action: 'read', subject: 'Flow', conditions: [] }, + ]); + + expect(await role.$relatedQuery('permissions')).toMatchObject([ + { + action: 'read', + subject: 'Flow', + conditions: [], + }, + ]); + }); + + it('should call Permission.filter', async () => { + const role = await createRole({ name: 'User' }); + + const permissions = [{ action: 'read', subject: 'Flow', conditions: [] }]; + + const permissionFilterSpy = vi + .spyOn(Permission, 'filter') + .mockReturnValue(permissions); + + await role.createPermissions(permissions); + + expect(permissionFilterSpy).toHaveBeenCalledWith(permissions); + }); + }); + + it('updatePermissions should delete existing permissions and create new permissions', async () => { + const permissionsData = [ + { action: 'read', subject: 'Flow', conditions: [] }, + ]; + + const deletePermissionsSpy = vi + .spyOn(Role.prototype, 'deletePermissions') + .mockResolvedValueOnce(); + const createPermissionsSpy = vi + .spyOn(Role.prototype, 'createPermissions') + .mockResolvedValueOnce(); + + const role = await createRole({ name: 'User' }); + + await role.updatePermissions(permissionsData); + + expect(deletePermissionsSpy.mock.invocationCallOrder[0]).toBeLessThan( + createPermissionsSpy.mock.invocationCallOrder[0] + ); + + expect(deletePermissionsSpy).toHaveBeenNthCalledWith(1); + expect(createPermissionsSpy).toHaveBeenNthCalledWith(1, permissionsData); + }); + + describe('updateWithPermissions', () => { + it('should update role along with given permissions', async () => { + const role = await createRole({ name: 'User' }); + await createPermission({ + roleId: role.id, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + const newRoleData = { + name: 'Updated user', + description: 'Updated description', + permissions: [ + { + action: 'update', + subject: 'Flow', + conditions: [], + }, + ], + }; + + await role.updateWithPermissions(newRoleData); + + const roleWithPermissions = await role + .$query() + .leftJoinRelated({ permissions: true }) + .withGraphFetched({ permissions: true }); + + expect(roleWithPermissions).toMatchObject(newRoleData); + }); + }); + + describe('deleteWithPermissions', () => { + it('should delete role along with given permissions', async () => { + const role = await createRole({ name: 'User' }); + await createPermission({ + roleId: role.id, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + await role.deleteWithPermissions(); + + const refetchedRole = await role.$query(); + const rolePermissions = await Permission.query().where({ + roleId: role.id, + }); + + expect(refetchedRole).toBe(undefined); + expect(rolePermissions).toStrictEqual([]); + }); + }); + + describe('assertNoRoleUserExists', () => { + it('should reject with an error when the role has users', async () => { + const role = await createRole({ name: 'User' }); + await createUser({ roleId: role.id }); + + await expect(() => role.assertNoRoleUserExists()).rejects.toThrowError( + `All users must be migrated away from the "User" role.` + ); + }); + + it('should resolve when the role does not have any users', async () => { + const role = await createRole(); + + expect(await role.assertNoRoleUserExists()).toBe(undefined); + }); + }); + + describe('assertNoConfigurationUsage', () => { + it('should reject with an error when the role is used in configuration', async () => { + const role = await createRole(); + await createSamlAuthProvider({ defaultRoleId: role.id }); + + await expect(() => + role.assertNoConfigurationUsage() + ).rejects.toThrowError( + 'samlAuthProvider: You need to change the default role in the SAML configuration before deleting this role.' + ); + }); + + it('should resolve when the role does not have any users', async () => { + const role = await createRole(); + + expect(await role.assertNoConfigurationUsage()).toBe(undefined); + }); + }); + + it('assertRoleIsNotUsed should call assertNoRoleUserExists and assertNoConfigurationUsage', async () => { + const role = new Role(); + + const assertNoRoleUserExistsSpy = vi + .spyOn(role, 'assertNoRoleUserExists') + .mockResolvedValue(); + + const assertNoConfigurationUsageSpy = vi + .spyOn(role, 'assertNoConfigurationUsage') + .mockResolvedValue(); + + await role.assertRoleIsNotUsed(); + + expect(assertNoRoleUserExistsSpy).toHaveBeenCalledOnce(); + expect(assertNoConfigurationUsageSpy).toHaveBeenCalledOnce(); + }); + + describe('$beforeDelete', () => { + it('should call preventAlteringAdmin', async () => { + const role = await createRole({ name: 'User' }); + + const preventAlteringAdminSpy = vi + .spyOn(role, 'preventAlteringAdmin') + .mockResolvedValue(); + + await role.$query().delete(); + + expect(preventAlteringAdminSpy).toHaveBeenCalledOnce(); + }); + + it('should call assertRoleIsNotUsed', async () => { + const role = await createRole({ name: 'User' }); + + const assertRoleIsNotUsedSpy = vi + .spyOn(role, 'assertRoleIsNotUsed') + .mockResolvedValue(); + + await role.$query().delete(); + + expect(assertRoleIsNotUsedSpy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/backend/src/models/saml-auth-provider.ee.js b/packages/backend/src/models/saml-auth-provider.ee.js new file mode 100644 index 0000000..4eb588e --- /dev/null +++ b/packages/backend/src/models/saml-auth-provider.ee.js @@ -0,0 +1,155 @@ +import { URL } from 'node:url'; +import { v4 as uuidv4 } from 'uuid'; +import isEmpty from 'lodash/isEmpty.js'; +import appConfig from '../config/app.js'; +import axios from '../helpers/axios-with-proxy.js'; +import Base from './base.js'; +import Identity from './identity.ee.js'; +import RoleMapping from './role-mapping.ee.js'; + +class SamlAuthProvider extends Base { + static tableName = 'saml_auth_providers'; + + static jsonSchema = { + type: 'object', + required: [ + 'name', + 'certificate', + 'signatureAlgorithm', + 'entryPoint', + 'issuer', + 'firstnameAttributeName', + 'surnameAttributeName', + 'emailAttributeName', + 'roleAttributeName', + 'defaultRoleId', + ], + + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + certificate: { type: 'string', minLength: 1 }, + signatureAlgorithm: { + type: 'string', + enum: ['sha1', 'sha256', 'sha512'], + }, + issuer: { type: 'string', minLength: 1 }, + entryPoint: { type: 'string', minLength: 1 }, + firstnameAttributeName: { type: 'string', minLength: 1 }, + surnameAttributeName: { type: 'string', minLength: 1 }, + emailAttributeName: { type: 'string', minLength: 1 }, + roleAttributeName: { type: 'string', minLength: 1 }, + defaultRoleId: { type: 'string', format: 'uuid' }, + active: { type: 'boolean' }, + }, + }; + + static relationMappings = () => ({ + identities: { + relation: Base.HasOneRelation, + modelClass: Identity, + join: { + from: 'identities.provider_id', + to: 'saml_auth_providers.id', + }, + }, + roleMappings: { + relation: Base.HasManyRelation, + modelClass: RoleMapping, + join: { + from: 'saml_auth_providers.id', + to: 'role_mappings.saml_auth_provider_id', + }, + }, + }); + + static get virtualAttributes() { + return ['loginUrl', 'remoteLogoutUrl']; + } + + get loginUrl() { + return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString(); + } + + get loginCallBackUrl() { + return new URL( + `/login/saml/${this.issuer}/callback`, + appConfig.baseUrl + ).toString(); + } + + get remoteLogoutUrl() { + return this.entryPoint; + } + + get config() { + return { + callbackUrl: this.loginCallBackUrl, + cert: this.certificate, + entryPoint: this.entryPoint, + issuer: this.issuer, + signatureAlgorithm: this.signatureAlgorithm, + logoutUrl: this.remoteLogoutUrl, + }; + } + + generateLogoutRequestBody(sessionId) { + const logoutRequest = ` + + + ${ + this.issuer + } + ${sessionId} + + `; + + const encodedLogoutRequest = Buffer.from(logoutRequest).toString('base64'); + + return encodedLogoutRequest; + } + + async terminateRemoteSession(sessionId) { + const logoutRequest = this.generateLogoutRequestBody(sessionId); + + const response = await axios.post( + this.remoteLogoutUrl, + new URLSearchParams({ + SAMLRequest: logoutRequest, + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + return response; + } + + async updateRoleMappings(roleMappings) { + await this.$relatedQuery('roleMappings').delete(); + + if (isEmpty(roleMappings)) { + return []; + } + + const roleMappingsData = roleMappings.map((roleMapping) => ({ + ...roleMapping, + samlAuthProviderId: this.id, + })); + + const newRoleMappings = await RoleMapping.query().insertAndFetch( + roleMappingsData + ); + + return newRoleMappings; + } +} + +export default SamlAuthProvider; diff --git a/packages/backend/src/models/saml-auth-provider.ee.test.js b/packages/backend/src/models/saml-auth-provider.ee.test.js new file mode 100644 index 0000000..10a0abb --- /dev/null +++ b/packages/backend/src/models/saml-auth-provider.ee.test.js @@ -0,0 +1,231 @@ +import { vi, beforeEach, describe, it, expect } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import SamlAuthProvider from '../models/saml-auth-provider.ee'; +import RoleMapping from '../models/role-mapping.ee'; +import axios from '../helpers/axios-with-proxy.js'; +import Identity from './identity.ee'; +import Base from './base'; +import appConfig from '../config/app'; +import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js'; +import { createRoleMapping } from '../../test/factories/role-mapping.js'; +import { createRole } from '../../test/factories/role.js'; + +describe('SamlAuthProvider model', () => { + it('tableName should return correct name', () => { + expect(SamlAuthProvider.tableName).toBe('saml_auth_providers'); + }); + + it('jsonSchema should have the correct schema', () => { + expect(SamlAuthProvider.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = SamlAuthProvider.relationMappings(); + + const expectedRelations = { + identities: { + relation: Base.HasOneRelation, + modelClass: Identity, + join: { + from: 'identities.provider_id', + to: 'saml_auth_providers.id', + }, + }, + roleMappings: { + relation: Base.HasManyRelation, + modelClass: RoleMapping, + join: { + from: 'saml_auth_providers.id', + to: 'role_mappings.saml_auth_provider_id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('virtualAttributes should return correct attributes', () => { + const virtualAttributes = SamlAuthProvider.virtualAttributes; + + const expectedAttributes = ['loginUrl', 'remoteLogoutUrl']; + + expect(virtualAttributes).toStrictEqual(expectedAttributes); + }); + + it('loginUrl should return the URL of login', () => { + const samlAuthProvider = new SamlAuthProvider(); + samlAuthProvider.issuer = 'sample-issuer'; + + vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + expect(samlAuthProvider.loginUrl).toStrictEqual( + 'https://automatisch.io/login/saml/sample-issuer' + ); + }); + + it('loginCallbackUrl should return the URL of login callback', () => { + const samlAuthProvider = new SamlAuthProvider(); + samlAuthProvider.issuer = 'sample-issuer'; + + vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + expect(samlAuthProvider.loginCallBackUrl).toStrictEqual( + 'https://automatisch.io/login/saml/sample-issuer/callback' + ); + }); + + it('remoteLogoutUrl should return the URL from entrypoint', () => { + const samlAuthProvider = new SamlAuthProvider(); + samlAuthProvider.entryPoint = 'https://example.com/saml/logout'; + + expect(samlAuthProvider.remoteLogoutUrl).toStrictEqual( + 'https://example.com/saml/logout' + ); + }); + + it('config should return the correct configuration object', () => { + const samlAuthProvider = new SamlAuthProvider(); + + samlAuthProvider.certificate = 'sample-certificate'; + samlAuthProvider.signatureAlgorithm = 'sha256'; + samlAuthProvider.entryPoint = 'https://example.com/saml'; + samlAuthProvider.issuer = 'sample-issuer'; + + vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + const expectedConfig = { + callbackUrl: 'https://automatisch.io/login/saml/sample-issuer/callback', + cert: 'sample-certificate', + entryPoint: 'https://example.com/saml', + issuer: 'sample-issuer', + signatureAlgorithm: 'sha256', + logoutUrl: 'https://example.com/saml', + }; + + expect(samlAuthProvider.config).toStrictEqual(expectedConfig); + }); + + it('generateLogoutRequestBody should return a correctly encoded SAML logout request', () => { + vi.mock('uuid', () => ({ + v4: vi.fn(), + })); + + const samlAuthProvider = new SamlAuthProvider(); + + samlAuthProvider.entryPoint = 'https://example.com/saml'; + samlAuthProvider.issuer = 'sample-issuer'; + + const mockUuid = '123e4567-e89b-12d3-a456-426614174000'; + uuidv4.mockReturnValue(mockUuid); + + const sessionId = 'test-session-id'; + + const logoutRequest = samlAuthProvider.generateLogoutRequestBody(sessionId); + + const expectedLogoutRequest = ` + + + sample-issuer + test-session-id + + `; + + const expectedEncodedRequest = Buffer.from(expectedLogoutRequest).toString( + 'base64' + ); + + expect(logoutRequest).toBe(expectedEncodedRequest); + }); + + it('terminateRemoteSession should send the correct POST request and return the response', async () => { + vi.mock('../helpers/axios-with-proxy.js', () => ({ + default: { + post: vi.fn(), + }, + })); + + const samlAuthProvider = new SamlAuthProvider(); + + samlAuthProvider.entryPoint = 'https://example.com/saml'; + samlAuthProvider.generateLogoutRequestBody = vi + .fn() + .mockReturnValue('mockEncodedLogoutRequest'); + + const sessionId = 'test-session-id'; + + const mockResponse = { data: 'Logout Successful' }; + axios.post.mockResolvedValue(mockResponse); + + const response = await samlAuthProvider.terminateRemoteSession(sessionId); + + expect(samlAuthProvider.generateLogoutRequestBody).toHaveBeenCalledWith( + sessionId + ); + + expect(axios.post).toHaveBeenCalledWith( + 'https://example.com/saml', + 'SAMLRequest=mockEncodedLogoutRequest', + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + expect(response).toBe(mockResponse); + }); + + describe('updateRoleMappings', () => { + let samlAuthProvider; + + beforeEach(async () => { + samlAuthProvider = await createSamlAuthProvider(); + }); + + it('should remove all existing role mappings', async () => { + await createRoleMapping({ + samlAuthProviderId: samlAuthProvider.id, + remoteRoleName: 'Admin', + }); + + await createRoleMapping({ + samlAuthProviderId: samlAuthProvider.id, + remoteRoleName: 'User', + }); + + await samlAuthProvider.updateRoleMappings([]); + + const roleMappings = await samlAuthProvider.$relatedQuery('roleMappings'); + expect(roleMappings).toStrictEqual([]); + }); + + it('should return the updated role mappings when new ones are provided', async () => { + const adminRole = await createRole({ name: 'Admin' }); + const userRole = await createRole({ name: 'User' }); + + const newRoleMappings = [ + { remoteRoleName: 'Admin', roleId: adminRole.id }, + { remoteRoleName: 'User', roleId: userRole.id }, + ]; + + const result = await samlAuthProvider.updateRoleMappings(newRoleMappings); + + const refetchedRoleMappings = await samlAuthProvider.$relatedQuery( + 'roleMappings' + ); + + expect(result).toStrictEqual(refetchedRoleMappings); + }); + }); +}); diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js new file mode 100644 index 0000000..3fe35db --- /dev/null +++ b/packages/backend/src/models/step.js @@ -0,0 +1,356 @@ +import { URL } from 'node:url'; +import Base from './base.js'; +import App from './app.js'; +import Flow from './flow.js'; +import Connection from './connection.js'; +import ExecutionStep from './execution-step.js'; +import Telemetry from '../helpers/telemetry/index.js'; +import appConfig from '../config/app.js'; +import globalVariable from '../helpers/global-variable.js'; +import computeParameters from '../helpers/compute-parameters.js'; +import testRun from '../services/test-run.js'; + +class Step extends Base { + static tableName = 'steps'; + + static jsonSchema = { + type: 'object', + required: ['type'], + + properties: { + id: { type: 'string', format: 'uuid' }, + flowId: { type: 'string', format: 'uuid' }, + key: { type: ['string', 'null'] }, + name: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, + appKey: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, + type: { type: 'string', enum: ['action', 'trigger'] }, + connectionId: { type: ['string', 'null'], format: 'uuid' }, + status: { + type: 'string', + enum: ['incomplete', 'completed'], + default: 'incomplete', + }, + position: { type: 'integer' }, + parameters: { type: 'object' }, + webhookPath: { type: ['string', 'null'] }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static get virtualAttributes() { + return ['iconUrl', 'webhookUrl']; + } + + static relationMappings = () => ({ + flow: { + relation: Base.BelongsToOneRelation, + modelClass: Flow, + join: { + from: 'steps.flow_id', + to: 'flows.id', + }, + }, + connection: { + relation: Base.HasOneRelation, + modelClass: Connection, + join: { + from: 'steps.connection_id', + to: 'connections.id', + }, + }, + lastExecutionStep: { + relation: Base.HasOneRelation, + modelClass: ExecutionStep, + join: { + from: 'steps.id', + to: 'execution_steps.step_id', + }, + filter(builder) { + builder.orderBy('created_at', 'desc').limit(1).first(); + }, + }, + executionSteps: { + relation: Base.HasManyRelation, + modelClass: ExecutionStep, + join: { + from: 'steps.id', + to: 'execution_steps.step_id', + }, + }, + }); + + get webhookUrl() { + if (!this.webhookPath) return null; + + return new URL(this.webhookPath, appConfig.webhookUrl).toString(); + } + + get iconUrl() { + if (!this.appKey) return null; + + return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`; + } + + get isTrigger() { + return this.type === 'trigger'; + } + + get isAction() { + return this.type === 'action'; + } + + async computeWebhookPath() { + if (this.type === 'action') return null; + + const triggerCommand = await this.getTriggerCommand(); + + if (!triggerCommand) return null; + + const isWebhook = triggerCommand.type === 'webhook'; + + if (!isWebhook) return null; + + if (this.parameters.workSynchronously) { + return `/webhooks/flows/${this.flowId}/sync`; + } + + if (triggerCommand.workSynchronously) { + return `/webhooks/flows/${this.flowId}/sync`; + } + + return `/webhooks/flows/${this.flowId}`; + } + + async getWebhookUrl() { + if (this.type === 'action') return; + + const path = await this.computeWebhookPath(); + const webhookUrl = new URL(path, appConfig.webhookUrl).toString(); + + return webhookUrl; + } + + async getApp() { + if (!this.appKey) return null; + + return await App.findOneByKey(this.appKey); + } + + async test() { + await testRun({ stepId: this.id }); + + const updatedStep = await this.$query() + .withGraphFetched('lastExecutionStep') + .patchAndFetch({ status: 'completed' }); + + return updatedStep; + } + + async getLastExecutionStep() { + return await this.$relatedQuery('lastExecutionStep'); + } + + async getNextStep() { + const flow = await this.$relatedQuery('flow'); + + return await flow + .$relatedQuery('steps') + .findOne({ position: this.position + 1 }); + } + + async getTriggerCommand() { + const { appKey, key, isTrigger } = this; + if (!isTrigger || !appKey || !key) return null; + + const app = await App.findOneByKey(appKey); + const command = app.triggers?.find((trigger) => trigger.key === key); + + return command; + } + + async getActionCommand() { + const { appKey, key, isAction } = this; + if (!isAction || !appKey || !key) return null; + + const app = await App.findOneByKey(appKey); + const command = app.actions?.find((action) => action.key === key); + + return command; + } + + async getSetupFields() { + let substeps; + + if (this.isTrigger) { + substeps = (await this.getTriggerCommand()).substeps; + } else { + substeps = (await this.getActionCommand()).substeps; + } + + const setupSubstep = substeps.find( + (substep) => substep.key === 'chooseTrigger' + ); + return setupSubstep.arguments; + } + + async getSetupAndDynamicFields() { + const setupFields = await this.getSetupFields(); + const setupAndDynamicFields = []; + + for (const setupField of setupFields) { + setupAndDynamicFields.push(setupField); + + const additionalFields = setupField.additionalFields; + if (additionalFields) { + const keyArgument = additionalFields.arguments.find( + (argument) => argument.name === 'key' + ); + const dynamicFieldsKey = keyArgument.value; + + const dynamicFields = await this.createDynamicFields( + dynamicFieldsKey, + this.parameters + ); + + setupAndDynamicFields.push(...dynamicFields); + } + } + + return setupAndDynamicFields; + } + + async createDynamicFields(dynamicFieldsKey, parameters) { + const connection = await this.$relatedQuery('connection'); + const flow = await this.$relatedQuery('flow'); + const app = await this.getApp(); + const $ = await globalVariable({ connection, app, flow, step: this }); + + const command = app.dynamicFields.find( + (data) => data.key === dynamicFieldsKey + ); + + for (const parameterKey in parameters) { + const parameterValue = parameters[parameterKey]; + $.step.parameters[parameterKey] = parameterValue; + } + + const dynamicFields = (await command.run($)) || []; + + return dynamicFields; + } + + async createDynamicData(dynamicDataKey, parameters) { + const connection = await this.$relatedQuery('connection'); + const flow = await this.$relatedQuery('flow'); + const app = await this.getApp(); + const $ = await globalVariable({ connection, app, flow, step: this }); + + const command = app.dynamicData.find((data) => data.key === dynamicDataKey); + + for (const parameterKey in parameters) { + const parameterValue = parameters[parameterKey]; + $.step.parameters[parameterKey] = parameterValue; + } + + const lastExecution = await flow.$relatedQuery('lastExecution'); + const lastExecutionId = lastExecution?.id; + + const priorExecutionSteps = lastExecutionId + ? await ExecutionStep.query().where({ + execution_id: lastExecutionId, + }) + : []; + + const setupAndDynamicFields = await this.getSetupAndDynamicFields(); + + const computedParameters = computeParameters( + $.step.parameters, + setupAndDynamicFields, + priorExecutionSteps + ); + + $.step.parameters = computedParameters; + const dynamicData = (await command.run($)).data; + + return dynamicData; + } + + async updateWebhookUrl() { + if (this.isAction) return this; + + const payload = { + webhookPath: await this.computeWebhookPath(), + }; + + await this.$query().patchAndFetch(payload); + + return this; + } + + async delete() { + await this.$relatedQuery('executionSteps').delete(); + await this.$query().delete(); + + const flow = await this.$relatedQuery('flow'); + + const nextSteps = await flow + .$relatedQuery('steps') + .where('position', '>', this.position); + + await flow.updateStepPositionsFrom(this.position, nextSteps); + } + + async updateFor(user, newStepData) { + const { + appKey = this.appKey, + name, + connectionId, + key, + parameters, + } = newStepData; + + if (connectionId && appKey) { + await user.authorizedConnections + .findOne({ + id: connectionId, + key: appKey, + }) + .throwIfNotFound(); + } + + if (this.isTrigger && appKey && key) { + await App.checkAppAndTrigger(appKey, key); + } + + if (this.isAction && appKey && key) { + await App.checkAppAndAction(appKey, key); + } + + const updatedStep = await this.$query().patchAndFetch({ + key, + name, + appKey, + connectionId: connectionId, + parameters: parameters, + status: 'incomplete', + }); + + await updatedStep.updateWebhookUrl(); + + return updatedStep; + } + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + Telemetry.stepCreated(this); + } + + async $afterUpdate(opt, queryContext) { + await super.$afterUpdate(opt, queryContext); + Telemetry.stepUpdated(this); + } +} + +export default Step; diff --git a/packages/backend/src/models/step.test.js b/packages/backend/src/models/step.test.js new file mode 100644 index 0000000..5660720 --- /dev/null +++ b/packages/backend/src/models/step.test.js @@ -0,0 +1,598 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import appConfig from '../config/app.js'; +import App from './app.js'; +import Base from './base.js'; +import Step from './step.js'; +import Flow from './flow.js'; +import Connection from './connection.js'; +import ExecutionStep from './execution-step.js'; +import Telemetry from '../helpers/telemetry/index.js'; +import * as testRunModule from '../services/test-run.js'; +import { createFlow } from '../../test/factories/flow.js'; +import { createUser } from '../../test/factories/user.js'; +import { createRole } from '../../test/factories/role.js'; +import { createPermission } from '../../test/factories/permission.js'; +import { createConnection } from '../../test/factories/connection.js'; +import { createStep } from '../../test/factories/step.js'; +import { createExecutionStep } from '../../test/factories/execution-step.js'; + +describe('Step model', () => { + it('tableName should return correct name', () => { + expect(Step.tableName).toBe('steps'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Step.jsonSchema).toMatchSnapshot(); + }); + + it('virtualAttributes should return correct attributes', () => { + const virtualAttributes = Step.virtualAttributes; + + const expectedAttributes = ['iconUrl', 'webhookUrl']; + + expect(virtualAttributes).toStrictEqual(expectedAttributes); + }); + + describe('relationMappings', () => { + it('should return correct associations', () => { + const relationMappings = Step.relationMappings(); + + const expectedRelations = { + flow: { + relation: Base.BelongsToOneRelation, + modelClass: Flow, + join: { + from: 'steps.flow_id', + to: 'flows.id', + }, + }, + connection: { + relation: Base.HasOneRelation, + modelClass: Connection, + join: { + from: 'steps.connection_id', + to: 'connections.id', + }, + }, + lastExecutionStep: { + relation: Base.HasOneRelation, + modelClass: ExecutionStep, + join: { + from: 'steps.id', + to: 'execution_steps.step_id', + }, + filter: expect.any(Function), + }, + executionSteps: { + relation: Base.HasManyRelation, + modelClass: ExecutionStep, + join: { + from: 'steps.id', + to: 'execution_steps.step_id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('lastExecutionStep should return the trigger step', () => { + const relations = Step.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const orderBySpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.lastExecutionStep.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('webhookUrl', () => { + it('should return it along with appConfig.webhookUrl when exists', () => { + vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + const step = new Step(); + step.webhookPath = '/webhook-path'; + + expect(step.webhookUrl).toBe('https://automatisch.io/webhook-path'); + }); + + it('should return null when webhookUrl does not exist', () => { + const step = new Step(); + + expect(step.webhookUrl).toBe(null); + }); + }); + + describe('iconUrl', () => { + it('should return step app icon absolute URL when app is set', () => { + vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + const step = new Step(); + step.appKey = 'gitlab'; + + expect(step.iconUrl).toBe( + 'https://automatisch.io/apps/gitlab/assets/favicon.svg' + ); + }); + + it('should return null when appKey is not set', () => { + const step = new Step(); + + expect(step.iconUrl).toBe(null); + }); + }); + + it('isTrigger should return true when step type is trigger', () => { + const step = new Step(); + step.type = 'trigger'; + + expect(step.isTrigger).toBe(true); + }); + + it('isAction should return true when step type is action', () => { + const step = new Step(); + step.type = 'action'; + + expect(step.isAction).toBe(true); + }); + + describe('computeWebhookPath', () => { + it('should return null if step type is action', async () => { + const step = new Step(); + step.type = 'action'; + + expect(await step.computeWebhookPath()).toBe(null); + }); + + it('should return null if triggerCommand is not found', async () => { + const step = new Step(); + step.type = 'trigger'; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue(null); + + expect(await step.computeWebhookPath()).toBe(null); + }); + + it('should return null if triggerCommand type is not webhook', async () => { + const step = new Step(); + step.type = 'trigger'; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue({ + type: 'not-webhook', + }); + + expect(await step.computeWebhookPath()).toBe(null); + }); + + it('should return synchronous webhook path if workSynchronously is true', async () => { + const step = new Step(); + step.type = 'trigger'; + step.flowId = 'flow-id'; + step.parameters = { workSynchronously: true }; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue({ + type: 'webhook', + }); + + expect(await step.computeWebhookPath()).toBe( + '/webhooks/flows/flow-id/sync' + ); + }); + + it('should return asynchronous webhook path if workSynchronously is false', async () => { + const step = new Step(); + step.type = 'trigger'; + step.flowId = 'flow-id'; + step.parameters = { workSynchronously: false }; + + vi.spyOn(step, 'getTriggerCommand').mockResolvedValue({ + type: 'webhook', + }); + + expect(await step.computeWebhookPath()).toBe('/webhooks/flows/flow-id'); + }); + }); + + describe('getWebhookUrl', () => { + it('should return absolute webhook URL when step type is trigger', async () => { + const step = new Step(); + step.type = 'trigger'; + + vi.spyOn(step, 'computeWebhookPath').mockResolvedValue('/webhook-path'); + vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + expect(await step.getWebhookUrl()).toBe( + 'https://automatisch.io/webhook-path' + ); + }); + + it('should return undefined when step type is action', async () => { + const step = new Step(); + step.type = 'action'; + + expect(await step.getWebhookUrl()).toBe(undefined); + }); + }); + describe('getApp', () => { + it('should return app with the given appKey', async () => { + const step = new Step(); + step.appKey = 'gitlab'; + + const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue(); + + await step.getApp(); + expect(findOneByKeySpy).toHaveBeenCalledWith('gitlab'); + }); + + it('should return null with no appKey', async () => { + const step = new Step(); + + const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue(); + + expect(await step.getApp()).toBe(null); + expect(findOneByKeySpy).not.toHaveBeenCalled(); + }); + }); + + it('test should execute the flow and mark the step as completed', async () => { + const step = await createStep({ status: 'incomplete' }); + + const testRunSpy = vi.spyOn(testRunModule, 'default').mockResolvedValue(); + + const updatedStep = await step.test(); + + expect(testRunSpy).toHaveBeenCalledWith({ stepId: step.id }); + expect(updatedStep.status).toBe('completed'); + }); + + it('getLastExecutionStep should return last execution step', async () => { + const step = await createStep(); + await createExecutionStep({ stepId: step.id }); + const secondExecutionStep = await createExecutionStep({ stepId: step.id }); + + expect(await step.getLastExecutionStep()).toStrictEqual( + secondExecutionStep + ); + }); + + it('getNextStep should return the next step', async () => { + const firstStep = await createStep(); + const secondStep = await createStep({ flowId: firstStep.flowId }); + const thirdStep = await createStep({ flowId: firstStep.flowId }); + + expect(await secondStep.getNextStep()).toStrictEqual(thirdStep); + }); + + describe('getTriggerCommand', () => { + it('should return trigger command when app key and key are defined in trigger step', async () => { + const step = new Step(); + step.type = 'trigger'; + step.appKey = 'webhook'; + step.key = 'catchRawWebhook'; + + const findOneByKeySpy = vi.spyOn(App, 'findOneByKey'); + const triggerCommand = await step.getTriggerCommand(); + + expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey); + expect(triggerCommand.key).toBe(step.key); + }); + + it('should return null when key is not defined', async () => { + const step = new Step(); + step.type = 'trigger'; + step.appKey = 'webhook'; + + expect(await step.getTriggerCommand()).toBe(null); + }); + }); + + describe('getActionCommand', () => { + it('should return action comamand when app key and key are defined in action step', async () => { + const step = new Step(); + step.type = 'action'; + step.appKey = 'ntfy'; + step.key = 'sendMessage'; + + const findOneByKeySpy = vi.spyOn(App, 'findOneByKey'); + const actionCommand = await step.getActionCommand(); + + expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey); + expect(actionCommand.key).toBe(step.key); + }); + + it('should return null when key is not defined', async () => { + const step = new Step(); + step.type = 'action'; + step.appKey = 'ntfy'; + + expect(await step.getActionCommand()).toBe(null); + }); + }); + + describe('getSetupFields', () => { + it('should return trigger setup substep fields in trigger step', async () => { + const step = new Step(); + step.appKey = 'webhook'; + step.key = 'catchRawWebhook'; + step.type = 'trigger'; + + expect(await step.getSetupFields()).toStrictEqual([ + { + label: 'Wait until flow is done', + key: 'workSynchronously', + type: 'dropdown', + required: true, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + ]); + }); + + it('should return action setup substep fields in action step', async () => { + const step = new Step(); + step.appKey = 'datastore'; + step.key = 'getValue'; + step.type = 'action'; + + expect(await step.getSetupFields()).toStrictEqual([ + { + label: 'Key', + key: 'key', + type: 'string', + required: true, + description: 'The key of your value to get.', + variables: true, + }, + ]); + }); + }); + + it.todo('getSetupAndDynamicFields'); + it.todo('createDynamicFields'); + it.todo('createDynamicData'); + + describe('updateWebhookUrl', () => { + it('should do nothing if step is an action', async () => { + const step = new Step(); + step.type = 'action'; + + await step.updateWebhookUrl(); + + expect(step.webhookUrl).toBeNull(); + }); + + it('should set webhookPath if step is a trigger', async () => { + const step = await createStep({ + type: 'trigger', + }); + + vi.spyOn(Step.prototype, 'computeWebhookPath').mockResolvedValue( + '/webhooks/flows/flow-id' + ); + + const newStep = await step.updateWebhookUrl(); + + expect(step.webhookPath).toBe('/webhooks/flows/flow-id'); + expect(newStep).toBe(step); + }); + + it('should return step itself after the update of webhook path', async () => { + const step = await createStep({ + type: 'trigger', + }); + + vi.spyOn(Step.prototype, 'computeWebhookPath').mockResolvedValue( + '/webhooks/flows/flow-id' + ); + + const updatedStep = await step.updateWebhookUrl(); + + expect(updatedStep).toStrictEqual(step); + }); + }); + + describe('delete', () => { + it('should delete the step and align the positions', async () => { + const flow = await createFlow(); + await createStep({ flowId: flow.id, position: 1, type: 'trigger' }); + await createStep({ flowId: flow.id, position: 2 }); + const stepToDelete = await createStep({ flowId: flow.id, position: 3 }); + await createStep({ flowId: flow.id, position: 4 }); + + await stepToDelete.delete(); + + const steps = await flow.$relatedQuery('steps'); + const stepIds = steps.map((step) => step.id); + + expect(stepIds).not.toContain(stepToDelete.id); + }); + + it('should align the positions of remaining steps', async () => { + const flow = await createFlow(); + await createStep({ flowId: flow.id, position: 1, type: 'trigger' }); + await createStep({ flowId: flow.id, position: 2 }); + const stepToDelete = await createStep({ flowId: flow.id, position: 3 }); + await createStep({ flowId: flow.id, position: 4 }); + + await stepToDelete.delete(); + + const steps = await flow.$relatedQuery('steps'); + const stepPositions = steps.map((step) => step.position); + + expect(stepPositions).toMatchObject([1, 2, 3]); + }); + + it('should delete related execution steps', async () => { + const step = await createStep(); + const executionStep = await createExecutionStep({ stepId: step.id }); + + await step.delete(); + + expect(await executionStep.$query()).toBe(undefined); + }); + }); + + describe('updateFor', async () => { + let step, + userRole, + user, + userConnection, + anotherUser, + anotherUserConnection; + + beforeEach(async () => { + userRole = await createRole({ name: 'User' }); + anotherUser = await createUser({ roleId: userRole.id }); + user = await createUser({ roleId: userRole.id }); + + userConnection = await createConnection({ + key: 'deepl', + userId: user.id, + }); + + anotherUserConnection = await createConnection({ + key: 'deepl', + userId: anotherUser.id, + }); + + await createPermission({ + roleId: userRole.id, + action: 'read', + subject: 'Connection', + conditions: ['isCreator'], + }); + + step = await createStep(); + }); + + it('should update step with the given payload and mark it as incomplete', async () => { + const stepData = { + appKey: 'deepl', + key: 'translateText', + connectionId: anotherUserConnection.id, + parameters: { + key: 'value', + }, + }; + + const anotherUserWithRoleAndPermissions = await anotherUser + .$query() + .withGraphFetched({ permissions: true, role: true }); + + const updatedStep = await step.updateFor( + anotherUserWithRoleAndPermissions, + stepData + ); + + expect(updatedStep).toMatchObject({ + ...stepData, + status: 'incomplete', + }); + }); + + it('should invoke updateWebhookUrl', async () => { + const updateWebhookUrlSpy = vi + .spyOn(Step.prototype, 'updateWebhookUrl') + .mockResolvedValue(); + + const stepData = { + appKey: 'deepl', + key: 'translateText', + }; + + await step.updateFor(user, stepData); + + expect(updateWebhookUrlSpy).toHaveBeenCalledOnce(); + }); + + it('should not update step when inaccessible connection is given', async () => { + const stepData = { + appKey: 'deepl', + key: 'translateText', + connectionId: userConnection.id, + }; + + const anotherUserWithRoleAndPermissions = await anotherUser + .$query() + .withGraphFetched({ permissions: true, role: true }); + + await expect(() => + step.updateFor(anotherUserWithRoleAndPermissions, stepData) + ).rejects.toThrowError('NotFoundError'); + }); + + it('should not update step when given app key and key do not exist', async () => { + const stepData = { + appKey: 'deepl', + key: 'not-existing-key', + }; + + await expect(() => step.updateFor(user, stepData)).rejects.toThrowError( + 'DeepL does not have an action with the "not-existing-key" key!' + ); + }); + }); + + describe('$afterInsert', () => { + it('should call super.$afterInsert', async () => { + const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert'); + + await createStep(); + + expect(superAfterInsertSpy).toHaveBeenCalled(); + }); + + it('should call Telemetry.stepCreated', async () => { + const telemetryStepCreatedSpy = vi + .spyOn(Telemetry, 'stepCreated') + .mockImplementation(() => {}); + + const step = await createStep(); + + expect(telemetryStepCreatedSpy).toHaveBeenCalledWith(step); + }); + }); + + describe('$afterUpdate', () => { + it('should call super.$afterUpdate', async () => { + const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate'); + + const step = await createStep(); + + await step.$query().patch({ position: 2 }); + + expect(superAfterUpdateSpy).toHaveBeenCalledOnce(); + }); + + it('$afterUpdate should call Telemetry.stepUpdated', async () => { + const telemetryStepUpdatedSpy = vi + .spyOn(Telemetry, 'stepUpdated') + .mockImplementation(() => {}); + + const step = await createStep(); + + await step.$query().patch({ position: 2 }); + + expect(telemetryStepUpdatedSpy).toHaveBeenCalled({}); + }); + }); +}); diff --git a/packages/backend/src/models/subscription.ee.js b/packages/backend/src/models/subscription.ee.js new file mode 100644 index 0000000..bcacf82 --- /dev/null +++ b/packages/backend/src/models/subscription.ee.js @@ -0,0 +1,89 @@ +import Base from './base.js'; +import User from './user.js'; +import UsageData from './usage-data.ee.js'; +import { DateTime } from 'luxon'; +import { getPlanById } from '../helpers/billing/plans.ee.js'; + +class Subscription extends Base { + static tableName = 'subscriptions'; + + static jsonSchema = { + type: 'object', + required: [ + 'userId', + 'paddleSubscriptionId', + 'paddlePlanId', + 'updateUrl', + 'cancelUrl', + 'status', + 'nextBillAmount', + 'nextBillDate', + ], + + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' }, + paddleSubscriptionId: { type: 'string' }, + paddlePlanId: { type: 'string' }, + updateUrl: { type: 'string' }, + cancelUrl: { type: 'string' }, + status: { type: 'string' }, + nextBillAmount: { type: 'string' }, + nextBillDate: { type: 'string' }, + lastBillDate: { type: 'string' }, + cancellationEffectiveDate: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'subscription.user_id', + to: 'users.id', + }, + }, + usageData: { + relation: Base.HasManyRelation, + modelClass: UsageData, + join: { + from: 'subscriptions.id', + to: 'usage_data.subscription_id', + }, + }, + currentUsageData: { + relation: Base.HasOneRelation, + modelClass: UsageData, + join: { + from: 'subscriptions.id', + to: 'usage_data.subscription_id', + }, + }, + }); + + get plan() { + return getPlanById(this.paddlePlanId); + } + + get isCancelledAndValid() { + return ( + this.status === 'deleted' && + Number(this.cancellationEffectiveDate) > + DateTime.now().startOf('day').toMillis() + ); + } + + get isValid() { + if (this.status === 'active') return true; + if (this.status === 'past_due') return true; + if (this.isCancelledAndValid) return true; + + return false; + } +} + +export default Subscription; diff --git a/packages/backend/src/models/subscription.ee.test.js b/packages/backend/src/models/subscription.ee.test.js new file mode 100644 index 0000000..35f24fa --- /dev/null +++ b/packages/backend/src/models/subscription.ee.test.js @@ -0,0 +1,108 @@ +import { vi, describe, it, expect } from 'vitest'; +import { DateTime } from 'luxon'; +import Subscription from './subscription.ee'; +import User from './user'; +import UsageData from './usage-data.ee'; +import Base from './base'; +import { createSubscription } from '../../test/factories/subscription'; + +describe('Subscription model', () => { + it('tableName should return correct name', () => { + expect(Subscription.tableName).toBe('subscriptions'); + }); + + it('jsonSchema should have correct validations', () => { + expect(Subscription.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = Subscription.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'subscription.user_id', + to: 'users.id', + }, + }, + usageData: { + relation: Base.HasManyRelation, + modelClass: UsageData, + join: { + from: 'subscriptions.id', + to: 'usage_data.subscription_id', + }, + }, + currentUsageData: { + relation: Base.HasOneRelation, + modelClass: UsageData, + join: { + from: 'subscriptions.id', + to: 'usage_data.subscription_id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('plan should return paddle plan data', async () => { + const subscription = await createSubscription({ + paddlePlanId: '47384', + }); + + const expectedPaddlePlan = { + limit: '10,000', + name: '10k - monthly', + price: '€20', + productId: '47384', + quota: 10000, + }; + + expect(subscription.plan).toStrictEqual(expectedPaddlePlan); + }); + + it('isCancelledAndValid should return true if deleted but cancellation effective date has not passed', async () => { + const subscription = await createSubscription({ + status: 'deleted', + cancellationEffectiveDate: DateTime.now().plus({ days: 2 }).toString(), + }); + + expect(subscription.isCancelledAndValid).toBe(true); + }); + + describe('isValid', () => { + it('should return true if status is active', async () => { + const subscription = await createSubscription({ + status: 'active', + }); + + expect(subscription.isValid).toBe(true); + }); + + it('should return true if status is past due', async () => { + const subscription = await createSubscription({ + status: 'past_due', + }); + + expect(subscription.isValid).toBe(true); + }); + + it('should return true if subscription is cancelled and valid', async () => { + const subscription = await createSubscription(); + vi.spyOn(subscription, 'isCancelledAndValid').mockReturnValue(false); + + expect(subscription.isValid).toBe(true); + }); + + it('should return false if any condition is matched', async () => { + const subscription = await createSubscription({ + status: 'not_valid', + }); + + expect(subscription.isValid).toBe(false); + }); + }); +}); diff --git a/packages/backend/src/models/usage-data.ee.js b/packages/backend/src/models/usage-data.ee.js new file mode 100644 index 0000000..eebfea3 --- /dev/null +++ b/packages/backend/src/models/usage-data.ee.js @@ -0,0 +1,51 @@ +import { raw } from 'objection'; +import Base from './base.js'; +import User from './user.js'; +import Subscription from './subscription.ee.js'; + +class UsageData extends Base { + static tableName = 'usage_data'; + + static jsonSchema = { + type: 'object', + required: ['userId', 'consumedTaskCount', 'nextResetAt'], + + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' }, + subscriptionId: { type: 'string', format: 'uuid' }, + consumedTaskCount: { type: 'integer' }, + nextResetAt: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + }, + subscription: { + relation: Base.BelongsToOneRelation, + modelClass: Subscription, + join: { + from: 'usage_data.subscription_id', + to: 'subscriptions.id', + }, + }, + }); + + async increaseConsumedTaskCountByOne() { + return await this.$query().patch({ + consumedTaskCount: raw('consumed_task_count + 1'), + }); + } +} + +export default UsageData; diff --git a/packages/backend/src/models/usage-data.ee.test.js b/packages/backend/src/models/usage-data.ee.test.js new file mode 100644 index 0000000..edfb36f --- /dev/null +++ b/packages/backend/src/models/usage-data.ee.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import UsageData from './usage-data.ee'; +import User from './user'; +import Subscription from './subscription.ee'; +import Base from './base'; +import { createUsageData } from '../../test/factories/usage-data'; + +describe('UsageData model', () => { + it('tableName should return correct name', () => { + expect(UsageData.tableName).toBe('usage_data'); + }); + + it('jsonSchema should have correct validations', () => { + expect(UsageData.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = UsageData.relationMappings(); + + const expectedRelations = { + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + }, + subscription: { + relation: Base.BelongsToOneRelation, + modelClass: Subscription, + join: { + from: 'usage_data.subscription_id', + to: 'subscriptions.id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('increaseConsumedTaskCountByOne should increase consumed task count by one', async () => { + const usageData = await createUsageData({ + consumedTaskCount: 1234, + }); + + await usageData.increaseConsumedTaskCountByOne(); + const refetchedUsageData = await usageData.$query(); + + expect(refetchedUsageData.consumedTaskCount).toStrictEqual(1235); + }); +}); diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js new file mode 100644 index 0000000..1ed7ece --- /dev/null +++ b/packages/backend/src/models/user.js @@ -0,0 +1,679 @@ +import bcrypt from 'bcrypt'; +import { DateTime, Duration } from 'luxon'; +import crypto from 'node:crypto'; +import { ValidationError } from 'objection'; + +import appConfig from '../config/app.js'; +import { hasValidLicense } from '../helpers/license.ee.js'; +import userAbility from '../helpers/user-ability.js'; +import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js'; +import Base from './base.js'; +import App from './app.js'; +import AccessToken from './access-token.js'; +import Connection from './connection.js'; +import Config from './config.js'; +import Execution from './execution.js'; +import ExecutionStep from './execution-step.js'; +import Flow from './flow.js'; +import Identity from './identity.ee.js'; +import Permission from './permission.js'; +import Role from './role.js'; +import Step from './step.js'; +import Subscription from './subscription.ee.js'; +import Folder from './folder.js'; +import UsageData from './usage-data.ee.js'; +import Billing from '../helpers/billing/index.ee.js'; +import NotAuthorizedError from '../errors/not-authorized.js'; + +import deleteUserQueue from '../queues/delete-user.ee.js'; +import flowQueue from '../queues/flow.js'; +import emailQueue from '../queues/email.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +class User extends Base { + static tableName = 'users'; + + static jsonSchema = { + type: 'object', + required: ['fullName', 'email'], + + properties: { + id: { type: 'string', format: 'uuid' }, + fullName: { type: 'string', minLength: 1 }, + email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, + password: { type: 'string', minLength: 6 }, + status: { + type: 'string', + enum: ['active', 'invited'], + default: 'active', + }, + resetPasswordToken: { type: ['string', 'null'] }, + resetPasswordTokenSentAt: { + type: ['string', 'null'], + format: 'date-time', + }, + invitationToken: { type: ['string', 'null'] }, + invitationTokenSentAt: { + type: ['string', 'null'], + format: 'date-time', + }, + trialExpiryDate: { type: 'string' }, + roleId: { type: 'string', format: 'uuid' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + accessTokens: { + relation: Base.HasManyRelation, + modelClass: AccessToken, + join: { + from: 'users.id', + to: 'access_tokens.user_id', + }, + }, + connections: { + relation: Base.HasManyRelation, + modelClass: Connection, + join: { + from: 'users.id', + to: 'connections.user_id', + }, + }, + flows: { + relation: Base.HasManyRelation, + modelClass: Flow, + join: { + from: 'users.id', + to: 'flows.user_id', + }, + }, + steps: { + relation: Base.ManyToManyRelation, + modelClass: Step, + join: { + from: 'users.id', + through: { + from: 'flows.user_id', + to: 'flows.id', + }, + to: 'steps.flow_id', + }, + }, + executions: { + relation: Base.ManyToManyRelation, + modelClass: Execution, + join: { + from: 'users.id', + through: { + from: 'flows.user_id', + to: 'flows.id', + }, + to: 'executions.flow_id', + }, + }, + usageData: { + relation: Base.HasManyRelation, + modelClass: UsageData, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + }, + currentUsageData: { + relation: Base.HasOneRelation, + modelClass: UsageData, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + filter(builder) { + builder.orderBy('created_at', 'desc').limit(1).first(); + }, + }, + subscriptions: { + relation: Base.HasManyRelation, + modelClass: Subscription, + join: { + from: 'subscriptions.user_id', + to: 'users.id', + }, + }, + currentSubscription: { + relation: Base.HasOneRelation, + modelClass: Subscription, + join: { + from: 'subscriptions.user_id', + to: 'users.id', + }, + filter(builder) { + builder.orderBy('created_at', 'desc').limit(1).first(); + }, + }, + role: { + relation: Base.HasOneRelation, + modelClass: Role, + join: { + from: 'roles.id', + to: 'users.role_id', + }, + }, + permissions: { + relation: Base.HasManyRelation, + modelClass: Permission, + join: { + from: 'users.role_id', + to: 'permissions.role_id', + }, + }, + identities: { + relation: Base.HasManyRelation, + modelClass: Identity, + join: { + from: 'identities.user_id', + to: 'users.id', + }, + }, + folders: { + relation: Base.HasManyRelation, + modelClass: Folder, + join: { + from: 'users.id', + to: 'folders.user_id', + }, + }, + }); + + static get virtualAttributes() { + return ['acceptInvitationUrl']; + } + + get authorizedFlows() { + const conditions = this.can('read', 'Flow'); + return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query(); + } + + get authorizedSteps() { + const conditions = this.can('read', 'Flow'); + return conditions.isCreator ? this.$relatedQuery('steps') : Step.query(); + } + + get authorizedConnections() { + const conditions = this.can('read', 'Connection'); + return conditions.isCreator + ? this.$relatedQuery('connections') + : Connection.query(); + } + + get authorizedExecutions() { + const conditions = this.can('read', 'Execution'); + return conditions.isCreator + ? this.$relatedQuery('executions') + : Execution.query(); + } + + get acceptInvitationUrl() { + return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`; + } + + get ability() { + return userAbility(this); + } + + static async authenticate(email, password) { + const user = await User.query().findOne({ + email: email?.toLowerCase() || null, + }); + + if (user && (await user.login(password))) { + const token = await createAuthTokenByUserId(user.id); + return token; + } + } + + async login(password) { + return await bcrypt.compare(password, this.password); + } + + async generateResetPasswordToken() { + const resetPasswordToken = crypto.randomBytes(64).toString('hex'); + const resetPasswordTokenSentAt = new Date().toISOString(); + + await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt }); + } + + async generateInvitationToken() { + const invitationToken = crypto.randomBytes(64).toString('hex'); + const invitationTokenSentAt = new Date().toISOString(); + + await this.$query().patchAndFetch({ + invitationToken, + invitationTokenSentAt, + }); + } + + async resetPassword(password) { + return await this.$query().patch({ + resetPasswordToken: null, + resetPasswordTokenSentAt: null, + password, + }); + } + + async acceptInvitation(password) { + return await this.$query().patch({ + invitationToken: null, + invitationTokenSentAt: null, + status: 'active', + password, + }); + } + + async updatePassword({ currentPassword, password }) { + if (await User.authenticate(this.email, currentPassword)) { + const user = await this.$query().patchAndFetch({ + password, + }); + + return user; + } + + throw new ValidationError({ + data: { + currentPassword: [ + { + message: 'is incorrect.', + }, + ], + }, + type: 'ValidationError', + }); + } + + async softRemove() { + await this.softRemoveAssociations(); + await this.$query().delete(); + + const jobName = `Delete user - ${this.id}`; + const jobPayload = { id: this.id }; + const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis(); + const jobOptions = { + delay: millisecondsFor30Days, + }; + + await deleteUserQueue.add(jobName, jobPayload, jobOptions); + } + + async softRemoveAssociations() { + const flows = await this.$relatedQuery('flows').where({ + active: true, + }); + + const repeatableJobs = await flowQueue.getRepeatableJobs(); + + for (const flow of flows) { + const job = repeatableJobs.find((job) => job.id === flow.id); + + if (job) { + await flowQueue.removeRepeatableByKey(job.key); + } + } + + const executionIds = ( + await this.$relatedQuery('executions').select('executions.id') + ).map((execution) => execution.id); + const flowIds = flows.map((flow) => flow.id); + + await this.$relatedQuery('accessTokens').delete(); + await ExecutionStep.query().delete().whereIn('execution_id', executionIds); + await this.$relatedQuery('executions').delete(); + await this.$relatedQuery('steps').delete(); + await Flow.query().whereIn('id', flowIds).delete(); + await this.$relatedQuery('connections').delete(); + await this.$relatedQuery('identities').delete(); + + if (appConfig.isCloud) { + await this.$relatedQuery('subscriptions').delete(); + await this.$relatedQuery('usageData').delete(); + } + } + + async sendResetPasswordEmail() { + await this.generateResetPasswordToken(); + + const jobName = `Reset Password Email - ${this.id}`; + + const jobPayload = { + email: this.email, + subject: 'Reset Password', + template: 'reset-password-instructions.ee', + params: { + token: this.resetPasswordToken, + webAppUrl: appConfig.webAppUrl, + fullName: this.fullName, + }, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + await emailQueue.add(jobName, jobPayload, jobOptions); + } + + isResetPasswordTokenValid() { + if (!this.resetPasswordTokenSentAt) { + return false; + } + + const sentAt = new Date(this.resetPasswordTokenSentAt); + const now = new Date(); + const fourHoursInMilliseconds = 1000 * 60 * 60 * 4; + + return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; + } + + async sendInvitationEmail() { + await this.generateInvitationToken(); + + const jobName = `Invitation Email - ${this.id}`; + + const jobPayload = { + email: this.email, + subject: 'You are invited!', + template: 'invitation-instructions', + params: { + fullName: this.fullName, + acceptInvitationUrl: this.acceptInvitationUrl, + }, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + await emailQueue.add(jobName, jobPayload, jobOptions); + } + + isInvitationTokenValid() { + if (!this.invitationTokenSentAt) { + return false; + } + + const sentAt = new Date(this.invitationTokenSentAt); + const now = new Date(); + const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72; + + return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds; + } + + async generateHash() { + if (this.password) { + this.password = await bcrypt.hash(this.password, 10); + } + } + + startTrialPeriod() { + this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); + } + + async isAllowedToRunFlows() { + if (appConfig.isSelfHosted) { + return true; + } + + if (await this.inTrial()) { + return true; + } + + if ((await this.hasActiveSubscription()) && (await this.withinLimits())) { + return true; + } + + return false; + } + + async inTrial() { + if (appConfig.isSelfHosted) { + return false; + } + + if (!this.trialExpiryDate) { + return false; + } + + if (await this.hasActiveSubscription()) { + return false; + } + + const expiryDate = DateTime.fromJSDate(this.trialExpiryDate); + const now = DateTime.now(); + + return now < expiryDate; + } + + async hasActiveSubscription() { + if (!appConfig.isCloud) { + return false; + } + + const subscription = await this.$relatedQuery('currentSubscription'); + + return subscription?.isValid; + } + + async withinLimits() { + const currentSubscription = await this.$relatedQuery('currentSubscription'); + const plan = currentSubscription.plan; + const currentUsageData = await this.$relatedQuery('currentUsageData'); + + return currentUsageData.consumedTaskCount < plan.quota; + } + + async getPlanAndUsage() { + const usageData = await this.$relatedQuery( + 'currentUsageData' + ).throwIfNotFound(); + + const subscription = await this.$relatedQuery('currentSubscription'); + + const currentPlan = Billing.paddlePlans.find( + (plan) => plan.productId === subscription?.paddlePlanId + ); + + const planAndUsage = { + usage: { + task: usageData.consumedTaskCount, + }, + plan: { + id: subscription?.paddlePlanId || null, + name: subscription ? currentPlan.name : 'Free Trial', + limit: currentPlan?.limit || null, + }, + }; + + return planAndUsage; + } + + async getInvoices() { + const subscription = await this.$relatedQuery('currentSubscription'); + + if (!subscription) { + return []; + } + + const invoices = await Billing.paddleClient.getInvoices( + Number(subscription.paddleSubscriptionId) + ); + + return invoices; + } + + async getApps(name) { + const connections = await this.authorizedConnections + .clone() + .select('connections.key') + .where({ draft: false }) + .count('connections.id as count') + .groupBy('connections.key'); + + const flows = await this.authorizedFlows + .clone() + .withGraphJoined('steps') + .orderBy('created_at', 'desc'); + + const duplicatedUsedApps = flows + .map((flow) => flow.steps.map((step) => step.appKey)) + .flat() + .filter(Boolean); + + const connectionKeys = connections.map((connection) => connection.key); + const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])]; + + let apps = await App.findAll(name); + + apps = apps + .filter((app) => { + return usedApps.includes(app.key); + }) + .map((app) => { + const connection = connections.find( + (connection) => connection.key === app.key + ); + + app.connectionCount = connection?.count || 0; + app.flowCount = 0; + + flows.forEach((flow) => { + const usedFlow = flow.steps.find((step) => step.appKey === app.key); + + if (usedFlow) { + app.flowCount += 1; + } + }); + + return app; + }) + .sort((appA, appB) => appA.name.localeCompare(appB.name)); + + return apps; + } + + static async createAdmin({ email, password, fullName }) { + const adminRole = await Role.findAdmin(); + + const adminUser = await this.query().insert({ + email, + password, + fullName, + roleId: adminRole.id, + }); + + await Config.markInstallationCompleted(); + + return adminUser; + } + + static async registerUser(userData) { + const { fullName, email, password } = userData; + + const role = await Role.query().findOne({ name: 'User' }).throwIfNotFound(); + + const user = await User.query().insertAndFetch({ + fullName, + email, + password, + roleId: role.id, + }); + + return user; + } + + can(action, subject) { + const can = this.ability.can(action, subject); + + if (!can) throw new NotAuthorizedError('The user is not authorized!'); + + const relevantRule = this.ability.relevantRuleFor(action, subject); + + const conditions = relevantRule?.conditions || []; + const conditionMap = Object.fromEntries( + conditions.map((condition) => [condition, true]) + ); + + return conditionMap; + } + + lowercaseEmail() { + if (this.email) { + this.email = this.email.toLowerCase(); + } + } + + async createUsageData() { + if (appConfig.isCloud) { + return await this.$relatedQuery('usageData').insertAndFetch({ + userId: this.id, + consumedTaskCount: 0, + nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(), + }); + } + } + + async omitEnterprisePermissionsWithoutValidLicense() { + if (await hasValidLicense()) { + return this; + } + + if (Array.isArray(this.permissions)) { + this.permissions = this.permissions.filter((permission) => { + const restrictedSubjects = [ + 'App', + 'Role', + 'SamlAuthProvider', + 'Config', + ]; + + return !restrictedSubjects.includes(permission.subject); + }); + } + } + + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + + this.lowercaseEmail(); + await this.generateHash(); + + if (appConfig.isCloud) { + this.startTrialPeriod(); + } + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + + this.lowercaseEmail(); + + await this.generateHash(); + } + + async $afterInsert(queryContext) { + await super.$afterInsert(queryContext); + + await this.createUsageData(); + } + + async $afterFind() { + await this.omitEnterprisePermissionsWithoutValidLicense(); + } +} + +export default User; diff --git a/packages/backend/src/models/user.test.js b/packages/backend/src/models/user.test.js new file mode 100644 index 0000000..a7920b9 --- /dev/null +++ b/packages/backend/src/models/user.test.js @@ -0,0 +1,1541 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DateTime, Duration } from 'luxon'; +import appConfig from '../config/app.js'; +import * as licenseModule from '../helpers/license.ee.js'; +import Base from './base.js'; +import AccessToken from './access-token.js'; +import Config from './config.js'; +import Connection from './connection.js'; +import Execution from './execution.js'; +import Flow from './flow.js'; +import Identity from './identity.ee.js'; +import Permission from './permission.js'; +import Role from './role.js'; +import Step from './step.js'; +import Subscription from './subscription.ee.js'; +import UsageData from './usage-data.ee.js'; +import Folder from './folder.js'; +import User from './user.js'; +import deleteUserQueue from '../queues/delete-user.ee.js'; +import emailQueue from '../queues/email.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; +import * as userAbilityModule from '../helpers/user-ability.js'; +import { createUser } from '../../test/factories/user.js'; +import { createConnection } from '../../test/factories/connection.js'; +import { createRole } from '../../test/factories/role.js'; +import { createPermission } from '../../test/factories/permission.js'; +import { createFlow } from '../../test/factories/flow.js'; +import { createStep } from '../../test/factories/step.js'; +import { createExecution } from '../../test/factories/execution.js'; +import { createSubscription } from '../../test/factories/subscription.js'; +import { createUsageData } from '../../test/factories/usage-data.js'; +import Billing from '../helpers/billing/index.ee.js'; + +describe('User model', () => { + it('tableName should return correct name', () => { + expect(User.tableName).toBe('users'); + }); + + it('jsonSchema should have correct validations', () => { + expect(User.jsonSchema).toMatchSnapshot(); + }); + + describe('relationMappings', () => { + it('should return correct associations', () => { + const relationMappings = User.relationMappings(); + + const expectedRelations = { + accessTokens: { + relation: Base.HasManyRelation, + modelClass: AccessToken, + join: { + from: 'users.id', + to: 'access_tokens.user_id', + }, + }, + connections: { + relation: Base.HasManyRelation, + modelClass: Connection, + join: { + from: 'users.id', + to: 'connections.user_id', + }, + }, + flows: { + relation: Base.HasManyRelation, + modelClass: Flow, + join: { + from: 'users.id', + to: 'flows.user_id', + }, + }, + steps: { + relation: Base.ManyToManyRelation, + modelClass: Step, + join: { + from: 'users.id', + through: { + from: 'flows.user_id', + to: 'flows.id', + }, + to: 'steps.flow_id', + }, + }, + executions: { + relation: Base.ManyToManyRelation, + modelClass: Execution, + join: { + from: 'users.id', + through: { + from: 'flows.user_id', + to: 'flows.id', + }, + to: 'executions.flow_id', + }, + }, + usageData: { + relation: Base.HasManyRelation, + modelClass: UsageData, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + }, + currentUsageData: { + relation: Base.HasOneRelation, + modelClass: UsageData, + join: { + from: 'usage_data.user_id', + to: 'users.id', + }, + filter: expect.any(Function), + }, + subscriptions: { + relation: Base.HasManyRelation, + modelClass: Subscription, + join: { + from: 'subscriptions.user_id', + to: 'users.id', + }, + }, + currentSubscription: { + relation: Base.HasOneRelation, + modelClass: Subscription, + join: { + from: 'subscriptions.user_id', + to: 'users.id', + }, + filter: expect.any(Function), + }, + role: { + relation: Base.HasOneRelation, + modelClass: Role, + join: { + from: 'roles.id', + to: 'users.role_id', + }, + }, + permissions: { + relation: Base.HasManyRelation, + modelClass: Permission, + join: { + from: 'users.role_id', + to: 'permissions.role_id', + }, + }, + identities: { + relation: Base.HasManyRelation, + modelClass: Identity, + join: { + from: 'identities.user_id', + to: 'users.id', + }, + }, + folders: { + relation: Base.HasManyRelation, + modelClass: Folder, + join: { + from: 'users.id', + to: 'folders.user_id', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + it('currentUsageData should return the current usage data', () => { + const relations = User.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const orderBySpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.currentUsageData.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + + it('currentSubscription should return the current subscription', () => { + const relations = User.relationMappings(); + + const firstSpy = vi.fn(); + + const limitSpy = vi.fn().mockImplementation(() => ({ + first: firstSpy, + })); + + const orderBySpy = vi.fn().mockImplementation(() => ({ + limit: limitSpy, + })); + + relations.currentSubscription.filter({ orderBy: orderBySpy }); + + expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); + expect(limitSpy).toHaveBeenCalledWith(1); + expect(firstSpy).toHaveBeenCalledOnce(); + }); + }); + + it('virtualAttributes should return correct attributes', () => { + const virtualAttributes = User.virtualAttributes; + + const expectedAttributes = ['acceptInvitationUrl']; + + expect(virtualAttributes).toStrictEqual(expectedAttributes); + }); + + describe('authorizedFlows', () => { + it('should return user flows with isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userFlow = await createFlow({ userId: user.id }); + await createFlow(); + + expect(await userWithRoleAndPermissions.authorizedFlows).toStrictEqual([ + userFlow, + ]); + }); + + it('should return all flows without isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userFlow = await createFlow({ userId: user.id }); + const anotherUserFlow = await createFlow(); + + expect(await userWithRoleAndPermissions.authorizedFlows).toStrictEqual([ + userFlow, + anotherUserFlow, + ]); + }); + + it('should throw an authorization error without Flow read permission', async () => { + const user = new User(); + + expect(() => user.authorizedFlows).toThrowError( + 'The user is not authorized!' + ); + }); + }); + + describe('authorizedSteps', () => { + it('should return user steps with isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userFlow = await createFlow({ userId: user.id }); + const userFlowStep = await createStep({ flowId: userFlow.id }); + const anotherUserFlow = await createFlow(); + await createStep({ flowId: anotherUserFlow.id }); + + expect(await userWithRoleAndPermissions.authorizedSteps).toStrictEqual([ + userFlowStep, + ]); + }); + + it('should return all steps without isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userFlow = await createFlow({ userId: user.id }); + const userFlowStep = await createStep({ flowId: userFlow.id }); + const anotherUserFlow = await createFlow(); + const anotherUserFlowStep = await createStep({ + flowId: anotherUserFlow.id, + }); + + expect(await userWithRoleAndPermissions.authorizedSteps).toStrictEqual([ + userFlowStep, + anotherUserFlowStep, + ]); + }); + + it('should throw an authorization error without Flow read permission', async () => { + const user = new User(); + + expect(() => user.authorizedSteps).toThrowError( + 'The user is not authorized!' + ); + }); + }); + + describe('authorizedConnections', () => { + it('should return user connections with isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Connection', + action: 'read', + conditions: ['isCreator'], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userConnection = await createConnection({ userId: user.id }); + await createConnection(); + + expect( + await userWithRoleAndPermissions.authorizedConnections + ).toStrictEqual([userConnection]); + }); + + it('should return all connections without isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Connection', + action: 'read', + conditions: [], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userConnection = await createConnection({ userId: user.id }); + const anotherUserConnection = await createConnection(); + + expect( + await userWithRoleAndPermissions.authorizedConnections.orderBy( + 'created_at', + 'asc' + ) + ).toStrictEqual([userConnection, anotherUserConnection]); + }); + + it('should throw an authorization error without Connection read permission', async () => { + const user = new User(); + + expect(() => user.authorizedConnections).toThrowError( + 'The user is not authorized!' + ); + }); + }); + + describe('authorizedExecutions', () => { + it('should return user executions with isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Execution', + action: 'read', + conditions: ['isCreator'], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userFlow = await createFlow({ userId: user.id }); + const userFlowExecution = await createExecution({ flowId: userFlow.id }); + await createExecution(); + + expect( + await userWithRoleAndPermissions.authorizedExecutions + ).toStrictEqual([userFlowExecution]); + }); + + it('should return all executions without isCreator condition', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Execution', + action: 'read', + conditions: [], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + const userFlow = await createFlow({ userId: user.id }); + const userFlowExecution = await createExecution({ flowId: userFlow.id }); + const anotherUserFlowExecution = await createExecution(); + + expect( + await userWithRoleAndPermissions.authorizedExecutions + ).toStrictEqual([userFlowExecution, anotherUserFlowExecution]); + }); + + it('should throw an authorization error without Execution read permission', async () => { + const user = new User(); + + expect(() => user.authorizedExecutions).toThrowError( + 'The user is not authorized!' + ); + }); + }); + + it('acceptInvitationUrl should return accept invitation page URL with invitation token', async () => { + const user = new User(); + user.invitationToken = 'invitation-token'; + + vi.spyOn(appConfig, 'webAppUrl', 'get').mockReturnValue( + 'https://automatisch.io' + ); + + expect(user.acceptInvitationUrl).toBe( + 'https://automatisch.io/accept-invitation?token=invitation-token' + ); + }); + + it('ability should return userAbility for the user', () => { + const user = new User(); + user.fullName = 'Sample user'; + + const userAbilitySpy = vi + .spyOn(userAbilityModule, 'default') + .mockReturnValue('user-ability'); + + expect(user.ability).toStrictEqual('user-ability'); + expect(userAbilitySpy).toHaveBeenNthCalledWith(1, user); + }); + + describe('authenticate', () => { + it('should create and return the token for correct email and password', async () => { + const user = await createUser({ + email: 'test-user@automatisch.io', + password: 'sample-password', + }); + + const token = await User.authenticate( + 'test-user@automatisch.io', + 'sample-password' + ); + + const persistedToken = await AccessToken.query().findOne({ + userId: user.id, + }); + + expect(token).toBe(persistedToken.token); + }); + + it('should return undefined for existing email and incorrect password', async () => { + await createUser({ + email: 'test-user@automatisch.io', + password: 'sample-password', + }); + + const token = await User.authenticate( + 'test-user@automatisch.io', + 'wrong-password' + ); + + expect(token).toBe(undefined); + }); + + it('should return undefined for non-existing email', async () => { + await createUser({ + email: 'test-user@automatisch.io', + password: 'sample-password', + }); + + const token = await User.authenticate('non-existing-user@automatisch.io'); + + expect(token).toBe(undefined); + }); + }); + + describe('login', () => { + it('should return true when the given password matches with the user password', async () => { + const user = await createUser({ password: 'sample-password' }); + + expect(await user.login('sample-password')).toBe(true); + }); + + it('should return false when the given password does not match with the user password', async () => { + const user = await createUser({ password: 'sample-password' }); + + expect(await user.login('wrong-password')).toBe(false); + }); + }); + + it('generateResetPasswordToken should persist a random reset password token with the current date', async () => { + vi.useFakeTimers(); + + const date = new Date(2024, 10, 11, 15, 17, 0, 0); + vi.setSystemTime(date); + + const user = await createUser({ + resetPasswordToken: null, + resetPasswordTokenSentAt: null, + }); + + await user.generateResetPasswordToken(); + + const refetchedUser = await user.$query(); + + expect(refetchedUser.resetPasswordToken.length).toBe(128); + expect(refetchedUser.resetPasswordTokenSentAt).toStrictEqual(date); + + vi.useRealTimers(); + }); + + it('generateInvitationToken should persist a random invitation token with the current date', async () => { + vi.useFakeTimers(); + + const date = new Date(2024, 10, 11, 15, 26, 0, 0); + vi.setSystemTime(date); + + const user = await createUser({ + invitationToken: null, + invitationTokenSentAt: null, + }); + + await user.generateInvitationToken(); + + const refetchedUser = await user.$query(); + + expect(refetchedUser.invitationToken.length).toBe(128); + expect(refetchedUser.invitationTokenSentAt).toStrictEqual(date); + + vi.useRealTimers(); + }); + + it('resetPassword should persist given password and remove reset password token', async () => { + const user = await createUser({ + resetPasswordToken: 'reset-password-token', + resetPasswordTokenSentAt: '2024-11-11T12:26:00.000Z', + }); + + await user.resetPassword('new-password'); + + const refetchedUser = await user.$query(); + + expect(refetchedUser.resetPasswordToken).toBe(null); + expect(refetchedUser.resetPasswordTokenSentAt).toBe(null); + expect(await refetchedUser.login('new-password')).toBe(true); + }); + + it('acceptInvitation should persist given password, set user active and remove invitation token', async () => { + const user = await createUser({ + invitationToken: 'invitation-token', + invitationTokenSentAt: '2024-11-11T12:26:00.000Z', + status: 'invited', + }); + + await user.acceptInvitation('new-password'); + + const refetchedUser = await user.$query(); + + expect(refetchedUser.invitationToken).toBe(null); + expect(refetchedUser.invitationTokenSentAt).toBe(null); + expect(refetchedUser.status).toBe('active'); + }); + + describe('updatePassword', () => { + it('should update password when the given current password matches with the user password', async () => { + const user = await createUser({ password: 'sample-password' }); + + const updatedUser = await user.updatePassword({ + currentPassword: 'sample-password', + password: 'new-password', + }); + + expect(await updatedUser.login('new-password')).toBe(true); + }); + + it('should throw validation error when the given current password does not match with the user password', async () => { + const user = await createUser({ password: 'sample-password' }); + + await expect( + user.updatePassword({ + currentPassword: 'wrong-password', + password: 'new-password', + }) + ).rejects.toThrowError('currentPassword: is incorrect.'); + }); + }); + + it('softRemove should soft remove the user, its associations and queue it for hard deletion in 30 days', async () => { + vi.useFakeTimers(); + + const date = new Date(2024, 10, 12, 12, 50, 0, 0); + vi.setSystemTime(date); + + const user = await createUser(); + + const softRemoveAssociationsSpy = vi + .spyOn(user, 'softRemoveAssociations') + .mockReturnValue(); + + const deleteUserQueueAddSpy = vi + .spyOn(deleteUserQueue, 'add') + .mockResolvedValue(); + + await user.softRemove(); + + const refetchedSoftDeletedUser = await user.$query().withSoftDeleted(); + + const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis(); + const jobName = `Delete user - ${user.id}`; + const jobPayload = { id: user.id }; + + const jobOptions = { + delay: millisecondsFor30Days, + }; + + expect(softRemoveAssociationsSpy).toHaveBeenCalledOnce(); + expect(refetchedSoftDeletedUser.deletedAt).toStrictEqual(date); + + expect(deleteUserQueueAddSpy).toHaveBeenCalledWith( + jobName, + jobPayload, + jobOptions + ); + + vi.useRealTimers(); + }); + + it.todo('softRemoveAssociations'); + + it('sendResetPasswordEmail should generate reset password token and queue to send reset password email', async () => { + vi.useFakeTimers(); + + const date = new Date(2024, 10, 12, 14, 33, 0, 0); + vi.setSystemTime(date); + + const user = await createUser(); + + const generateResetPasswordTokenSpy = vi + .spyOn(user, 'generateResetPasswordToken') + .mockReturnValue(); + + const emailQueueAddSpy = vi.spyOn(emailQueue, 'add').mockResolvedValue(); + + await user.sendResetPasswordEmail(); + + const refetchedUser = await user.$query(); + const jobName = `Reset Password Email - ${user.id}`; + + const jobPayload = { + email: refetchedUser.email, + subject: 'Reset Password', + template: 'reset-password-instructions.ee', + params: { + token: refetchedUser.resetPasswordToken, + webAppUrl: appConfig.webAppUrl, + fullName: refetchedUser.fullName, + }, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + expect(generateResetPasswordTokenSpy).toHaveBeenCalledOnce(); + + expect(emailQueueAddSpy).toHaveBeenCalledWith( + jobName, + jobPayload, + jobOptions + ); + + vi.useRealTimers(); + }); + + describe('isResetPasswordTokenValid', () => { + it('should return true when resetPasswordTokenSentAt is within the next four hours', async () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 12, hour: 16, minute: 30 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = new User(); + user.resetPasswordTokenSentAt = '2024-11-12T13:31:00.000Z'; + + expect(user.isResetPasswordTokenValid()).toBe(true); + + vi.useRealTimers(); + }); + + it('should return false when there is no resetPasswordTokenSentAt', async () => { + const user = new User(); + + expect(user.isResetPasswordTokenValid()).toBe(false); + }); + + it('should return false when resetPasswordTokenSentAt is older than four hours', async () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 12, hour: 16, minute: 30 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = new User(); + user.resetPasswordTokenSentAt = '2024-11-12T12:29:00.000Z'; + + expect(user.isResetPasswordTokenValid()).toBe(false); + + vi.useRealTimers(); + }); + }); + + it('sendInvitationEmail should generate invitation token and queue to send invitation email', async () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 12, hour: 17, minute: 10 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = await createUser(); + + const generateInvitationTokenSpy = vi + .spyOn(user, 'generateInvitationToken') + .mockReturnValue(); + + const emailQueueAddSpy = vi.spyOn(emailQueue, 'add').mockResolvedValue(); + + await user.sendInvitationEmail(); + + const refetchedUser = await user.$query(); + const jobName = `Invitation Email - ${refetchedUser.id}`; + + const jobPayload = { + email: refetchedUser.email, + subject: 'You are invited!', + template: 'invitation-instructions', + params: { + fullName: refetchedUser.fullName, + acceptInvitationUrl: refetchedUser.acceptInvitationUrl, + }, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + expect(generateInvitationTokenSpy).toHaveBeenCalledOnce(); + + expect(emailQueueAddSpy).toHaveBeenCalledWith( + jobName, + jobPayload, + jobOptions + ); + + vi.useRealTimers(); + }); + + describe('isInvitationTokenValid', () => { + it('should return truen when invitationTokenSentAt is within the next four hours', async () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 14, hour: 14, minute: 30 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = new User(); + user.invitationTokenSentAt = '2024-11-14T13:31:00.000Z'; + + expect(user.isInvitationTokenValid()).toBe(true); + + vi.useRealTimers(); + }); + + it('should return false when there is no invitationTokenSentAt', async () => { + const user = new User(); + + expect(user.isInvitationTokenValid()).toBe(false); + }); + + it('should return false when invitationTokenSentAt is older than seventy two hours', async () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 14, hour: 14, minute: 30 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = new User(); + user.invitationTokenSentAt = '2024-11-11T14:20:00.000Z'; + + expect(user.isInvitationTokenValid()).toBe(false); + + vi.useRealTimers(); + }); + }); + + describe('generateHash', () => { + it('should hash password and re-assign it', async () => { + const user = new User(); + user.password = 'sample-password'; + + await user.generateHash(); + + expect(user.password).not.toBe('sample-password'); + expect(await user.login('sample-password')).toBe(true); + }); + + it('should do nothing when password does not exist', async () => { + const user = new User(); + + await user.generateHash(); + + expect(user.password).toBe(undefined); + }); + }); + + it('startTrialPeriod should assign trialExpiryDate 30 days from now', () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 14, hour: 16 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = new User(); + + user.startTrialPeriod(); + + expect(user.trialExpiryDate).toBe('2024-12-14'); + + vi.useRealTimers(); + }); + + describe('isAllowedToRunFlows', () => { + it('should return true when Automatisch is self hosted', async () => { + const user = new User(); + + vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(true); + + expect(await user.isAllowedToRunFlows()).toBe(true); + }); + + it('should return true when the user is in trial', async () => { + const user = new User(); + + vi.spyOn(user, 'inTrial').mockResolvedValue(true); + + expect(await user.isAllowedToRunFlows()).toBe(true); + }); + + it('should return true when the user has active subscription and within quota limits', async () => { + const user = new User(); + + vi.spyOn(user, 'hasActiveSubscription').mockResolvedValue(true); + vi.spyOn(user, 'withinLimits').mockResolvedValue(true); + + expect(await user.isAllowedToRunFlows()).toBe(true); + }); + + it('should return false when the user has active subscription over quota limits', async () => { + const user = new User(); + + vi.spyOn(user, 'hasActiveSubscription').mockResolvedValue(true); + vi.spyOn(user, 'withinLimits').mockResolvedValue(false); + + expect(await user.isAllowedToRunFlows()).toBe(false); + }); + + it('should return false otherwise', async () => { + const user = new User(); + + expect(await user.isAllowedToRunFlows()).toBe(false); + }); + }); + + describe('inTrial', () => { + it('should return false when Automatisch is self hosted', async () => { + const user = new User(); + + vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(true); + + expect(await user.inTrial()).toBe(false); + }); + + it('should return false when the user does not have trial expiry date', async () => { + const user = new User(); + + vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false); + + expect(await user.inTrial()).toBe(false); + }); + + it('should return false when the user has an active subscription', async () => { + const user = new User(); + user.trialExpiryDate = '2024-12-14'; + + vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false); + + const hasActiveSubscriptionSpy = vi + .spyOn(user, 'hasActiveSubscription') + .mockResolvedValue(true); + + expect(await user.inTrial()).toBe(false); + expect(hasActiveSubscriptionSpy).toHaveBeenCalledOnce(); + }); + + it('should return true when trial expiry date is in future', async () => { + vi.useFakeTimers(); + + const date = DateTime.fromObject( + { year: 2024, month: 11, day: 12, hour: 17, minute: 30 }, + { zone: 'UTC+0' } + ); + + vi.setSystemTime(date); + + const user = await createUser(); + + await user.startTrialPeriod(); + + const refetchedUser = await user.$query(); + + vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false); + vi.spyOn(refetchedUser, 'hasActiveSubscription').mockResolvedValue(false); + + expect(await refetchedUser.inTrial()).toBe(true); + + vi.useRealTimers(); + }); + + it('should return false when trial expiry date is in past', async () => { + vi.useFakeTimers(); + + const user = await createUser(); + + await user.startTrialPeriod(); + + vi.setSystemTime(DateTime.now().plus({ days: 31 })); + + const refetchedUser = await user.$query(); + + vi.spyOn(appConfig, 'isSelfHosted', 'get').mockReturnValue(false); + vi.spyOn(refetchedUser, 'hasActiveSubscription').mockResolvedValue(false); + + expect(await refetchedUser.inTrial()).toBe(false); + + vi.useRealTimers(); + }); + }); + + describe('hasActiveSubscription', () => { + it('should return true if current subscription is valid', async () => { + const user = await createUser(); + await createSubscription({ userId: user.id, status: 'active' }); + + expect(await user.hasActiveSubscription()).toBe(true); + }); + + it('should return false if current subscription is not valid', async () => { + const user = await createUser(); + + await createSubscription({ + userId: user.id, + status: 'deleted', + cancellationEffectiveDate: DateTime.now().minus({ day: 1 }).toString(), + }); + + expect(await user.hasActiveSubscription()).toBe(false); + }); + + it('should return false if Automatisch is not a cloud installation', async () => { + const user = new User(); + + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false); + + expect(await user.hasActiveSubscription()).toBe(false); + }); + }); + + describe('withinLimits', () => { + it('should return true when the consumed task count is less than the quota', async () => { + const user = await createUser(); + const subscription = await createSubscription({ userId: user.id }); + + await createUsageData({ + subscriptionId: subscription.id, + userId: user.id, + consumedTaskCount: 100, + }); + + expect(await user.withinLimits()).toBe(true); + }); + + it('should return true when the consumed task count is less than the quota', async () => { + const user = await createUser(); + const subscription = await createSubscription({ userId: user.id }); + + await createUsageData({ + subscriptionId: subscription.id, + userId: user.id, + consumedTaskCount: 10000, + }); + + expect(await user.withinLimits()).toBe(false); + }); + }); + + describe('getPlanAndUsage', () => { + it('should return plan and usage', async () => { + const user = await createUser(); + + const subscription = await createSubscription({ userId: user.id }); + + expect(await user.getPlanAndUsage()).toStrictEqual({ + usage: { + task: 0, + }, + plan: { + id: subscription.paddlePlanId, + name: '10k - monthly', + limit: '10,000', + }, + }); + }); + + it('should return trial plan and usage if no subscription exists', async () => { + const user = await createUser(); + + expect(await user.getPlanAndUsage()).toStrictEqual({ + usage: { + task: 0, + }, + plan: { + id: null, + name: 'Free Trial', + limit: null, + }, + }); + }); + + it('should throw not found when the current usage data does not exist', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false); + + const user = await createUser(); + + await expect(() => user.getPlanAndUsage()).rejects.toThrow( + 'NotFoundError' + ); + }); + }); + + describe('getInvoices', () => { + it('should return invoices for the current subscription', async () => { + const user = await createUser(); + const subscription = await createSubscription({ userId: user.id }); + + const getInvoicesSpy = vi + .spyOn(Billing.paddleClient, 'getInvoices') + .mockResolvedValue('dummy-invoices'); + + expect(await user.getInvoices()).toBe('dummy-invoices'); + expect(getInvoicesSpy).toHaveBeenCalledWith( + Number(subscription.paddleSubscriptionId) + ); + }); + + it('should return empty array without any subscriptions', async () => { + const user = await createUser(); + + expect(await user.getInvoices()).toStrictEqual([]); + }); + }); + + it.todo('getApps'); + + it('createAdmin should create admin with given data and mark the installation completed', async () => { + const adminRole = await createRole({ name: 'Admin' }); + + const markInstallationCompletedSpy = vi + .spyOn(Config, 'markInstallationCompleted') + .mockResolvedValue(); + + const adminUser = await User.createAdmin({ + fullName: 'Sample admin', + email: 'admin@automatisch.io', + password: 'sample', + }); + + expect(adminUser).toMatchObject({ + fullName: 'Sample admin', + email: 'admin@automatisch.io', + roleId: adminRole.id, + }); + + expect(markInstallationCompletedSpy).toHaveBeenCalledOnce(); + expect(await adminUser.login('sample')).toBe(true); + }); + + describe('registerUser', () => { + it('should register user with user role and given data', async () => { + const userRole = await createRole({ name: 'User' }); + + const user = await User.registerUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + password: 'sample-password', + }); + + expect(user).toMatchObject({ + fullName: 'Sample user', + email: 'user@automatisch.io', + roleId: userRole.id, + }); + + expect(await user.login('sample-password')).toBe(true); + }); + + it('should throw not found error when user role does not exist', async () => { + await expect(() => + User.registerUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + password: 'sample-password', + }) + ).rejects.toThrowError('NotFoundError'); + }); + }); + + describe('can', () => { + it('should return conditions for the given action and subject of the user', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'Connection', + action: 'read', + conditions: [], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + expect(userWithRoleAndPermissions.can('read', 'Flow')).toStrictEqual({ + isCreator: true, + }); + + expect( + userWithRoleAndPermissions.can('read', 'Connection') + ).toStrictEqual({}); + }); + + it('should return not authorized error when the user is not permitted for the given action and subject', async () => { + const userRole = await createRole({ name: 'User' }); + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + expect(() => userWithRoleAndPermissions.can('read', 'Flow')).toThrowError( + 'The user is not authorized!' + ); + }); + }); + + it('lowercaseEmail should lowercase the user email', () => { + const user = new User(); + user.email = 'USER@AUTOMATISCH.IO'; + + user.lowercaseEmail(); + + expect(user.email).toBe('user@automatisch.io'); + }); + + describe('createUsageData', () => { + it('should create usage data if Automatisch is a cloud installation', async () => { + vi.useFakeTimers(); + + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + vi.setSystemTime(DateTime.now().plus({ month: 1 })); + + const usageData = await user.createUsageData(); + const currentUsageData = await user.$relatedQuery('currentUsageData'); + + expect(usageData).toStrictEqual(currentUsageData); + + vi.useRealTimers(); + }); + + it('should not create usage data if Automatisch is not a cloud installation', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false); + + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + const usageData = await user.createUsageData(); + + expect(usageData).toBe(undefined); + }); + }); + + describe('omitEnterprisePermissionsWithoutValidLicense', () => { + it('should return user as-is with valid license', async () => { + const userRole = await createRole({ name: 'User' }); + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + roleId: userRole.id, + }); + + const readFlowPermission = await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'App', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'Role', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'Config', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'SamlAuthProvider', + action: 'read', + conditions: [], + }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + expect(userWithRoleAndPermissions.permissions).toStrictEqual([ + readFlowPermission, + ]); + }); + + it('should omit enterprise permissions without valid license', async () => { + vi.spyOn(licenseModule, 'hasValidLicense').mockResolvedValue(false); + + const userRole = await createRole({ name: 'User' }); + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + roleId: userRole.id, + }); + + const readFlowPermission = await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'App', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'Role', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'Config', + action: 'read', + conditions: [], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'SamlAuthProvider', + action: 'read', + conditions: [], + }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + expect(userWithRoleAndPermissions.permissions).toStrictEqual([ + readFlowPermission, + ]); + }); + }); + + describe('$beforeInsert', () => { + it('should call super.$beforeInsert', async () => { + const superBeforeInsertSpy = vi + .spyOn(User.prototype, '$beforeInsert') + .mockResolvedValue(); + + await createUser(); + + expect(superBeforeInsertSpy).toHaveBeenCalledOnce(); + }); + + it('should lowercase the user email', async () => { + const user = await createUser({ + fullName: 'Sample user', + email: 'USER@AUTOMATISCH.IO', + }); + + expect(user.email).toBe('user@automatisch.io'); + }); + + it('should generate password hash', async () => { + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + password: 'sample-password', + }); + + expect(user.password).not.toBe('sample-password'); + expect(await user.login('sample-password')).toBe(true); + }); + + it('should start trial period if Automatisch is a cloud installation', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + + const startTrialPeriodSpy = vi.spyOn(User.prototype, 'startTrialPeriod'); + + await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + expect(startTrialPeriodSpy).toHaveBeenCalledOnce(); + }); + + it('should not start trial period if Automatisch is not a cloud installation', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false); + + const startTrialPeriodSpy = vi.spyOn(User.prototype, 'startTrialPeriod'); + + await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + expect(startTrialPeriodSpy).not.toHaveBeenCalled(); + }); + }); + + describe('$beforeUpdate', () => { + it('should call super.$beforeUpdate', async () => { + const superBeforeUpdateSpy = vi + .spyOn(User.prototype, '$beforeUpdate') + .mockResolvedValue(); + + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + await user.$query().patch({ fullName: 'Updated user name' }); + + expect(superBeforeUpdateSpy).toHaveBeenCalledOnce(); + }); + + it('should lowercase the user email if given', async () => { + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + await user.$query().patchAndFetch({ email: 'NEW_EMAIL@AUTOMATISCH.IO' }); + + expect(user.email).toBe('new_email@automatisch.io'); + }); + + it('should generate password hash', async () => { + const user = await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + password: 'sample-password', + }); + + await user.$query().patchAndFetch({ password: 'new-password' }); + + expect(user.password).not.toBe('new-password'); + expect(await user.login('new-password')).toBe(true); + }); + }); + + describe('$afterInsert', () => { + it('should call super.$afterInsert', async () => { + const superAfterInsertSpy = vi.spyOn(User.prototype, '$afterInsert'); + + await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + expect(superAfterInsertSpy).toHaveBeenCalledOnce(); + }); + + it('should call createUsageData', async () => { + const createUsageDataSpy = vi.spyOn(User.prototype, 'createUsageData'); + + await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + expect(createUsageDataSpy).toHaveBeenCalledOnce(); + }); + }); + + it('$afterFind should invoke omitEnterprisePermissionsWithoutValidLicense method', async () => { + const omitEnterprisePermissionsWithoutValidLicenseSpy = vi.spyOn( + User.prototype, + 'omitEnterprisePermissionsWithoutValidLicense' + ); + + await createUser({ + fullName: 'Sample user', + email: 'user@automatisch.io', + }); + + expect( + omitEnterprisePermissionsWithoutValidLicenseSpy + ).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/backend/src/queues/action.js b/packages/backend/src/queues/action.js new file mode 100644 index 0000000..dbb0226 --- /dev/null +++ b/packages/backend/src/queues/action.js @@ -0,0 +1,4 @@ +import { generateQueue } from './queue.js'; + +const actionQueue = generateQueue('action'); +export default actionQueue; diff --git a/packages/backend/src/queues/delete-user.ee.js b/packages/backend/src/queues/delete-user.ee.js new file mode 100644 index 0000000..8e93952 --- /dev/null +++ b/packages/backend/src/queues/delete-user.ee.js @@ -0,0 +1,4 @@ +import { generateQueue } from './queue.js'; + +const deleteUserQueue = generateQueue('delete-user'); +export default deleteUserQueue; diff --git a/packages/backend/src/queues/email.js b/packages/backend/src/queues/email.js new file mode 100644 index 0000000..31e55bd --- /dev/null +++ b/packages/backend/src/queues/email.js @@ -0,0 +1,4 @@ +import { generateQueue } from './queue.js'; + +const emailQueue = generateQueue('email'); +export default emailQueue; diff --git a/packages/backend/src/queues/flow.js b/packages/backend/src/queues/flow.js new file mode 100644 index 0000000..b9d335f --- /dev/null +++ b/packages/backend/src/queues/flow.js @@ -0,0 +1,4 @@ +import { generateQueue } from './queue.js'; + +const flowQueue = generateQueue('flow'); +export default flowQueue; diff --git a/packages/backend/src/queues/index.js b/packages/backend/src/queues/index.js new file mode 100644 index 0000000..fcdd4c8 --- /dev/null +++ b/packages/backend/src/queues/index.js @@ -0,0 +1,21 @@ +import appConfig from '../config/app.js'; +import actionQueue from './action.js'; +import emailQueue from './email.js'; +import flowQueue from './flow.js'; +import triggerQueue from './trigger.js'; +import deleteUserQueue from './delete-user.ee.js'; +import removeCancelledSubscriptionsQueue from './remove-cancelled-subscriptions.ee.js'; + +const queues = [ + actionQueue, + emailQueue, + flowQueue, + triggerQueue, + deleteUserQueue, +]; + +if (appConfig.isCloud) { + queues.push(removeCancelledSubscriptionsQueue); +} + +export default queues; diff --git a/packages/backend/src/queues/queue.js b/packages/backend/src/queues/queue.js new file mode 100644 index 0000000..f6a5263 --- /dev/null +++ b/packages/backend/src/queues/queue.js @@ -0,0 +1,44 @@ +import process from 'process'; +import { Queue } from 'bullmq'; +import redisConfig from '../config/redis.js'; +import logger from '../helpers/logger.js'; + +const CONNECTION_REFUSED = 'ECONNREFUSED'; + +const redisConnection = { + connection: redisConfig, +}; + +export const generateQueue = (queueName, options) => { + const queue = new Queue(queueName, redisConnection); + + queue.on('error', (error) => queueOnError(error, queueName)); + + if (options?.runDaily) addScheduler(queueName, queue); + + return queue; +}; + +const queueOnError = (error, queueName) => { + if (error.code === CONNECTION_REFUSED) { + const errorMessage = + 'Make sure you have installed Redis and it is running.'; + + logger.error(errorMessage, error); + + process.exit(); + } + + logger.error(`Error happened in ${queueName} queue!`, error); +}; + +const addScheduler = (queueName, queue) => { + const everydayAtOneOclock = '0 1 * * *'; + + queue.add(queueName, null, { + jobId: queueName, + repeat: { + pattern: everydayAtOneOclock, + }, + }); +}; diff --git a/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js b/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js new file mode 100644 index 0000000..bb43972 --- /dev/null +++ b/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js @@ -0,0 +1,8 @@ +import { generateQueue } from './queue.js'; + +const removeCancelledSubscriptionsQueue = generateQueue( + 'remove-cancelled-subscriptions', + { runDaily: true } +); + +export default removeCancelledSubscriptionsQueue; diff --git a/packages/backend/src/queues/trigger.js b/packages/backend/src/queues/trigger.js new file mode 100644 index 0000000..e2134e1 --- /dev/null +++ b/packages/backend/src/queues/trigger.js @@ -0,0 +1,4 @@ +import { generateQueue } from './queue.js'; + +const triggerQueue = generateQueue('trigger'); +export default triggerQueue; diff --git a/packages/backend/src/routes/api/v1/access-tokens.js b/packages/backend/src/routes/api/v1/access-tokens.js new file mode 100644 index 0000000..95dff38 --- /dev/null +++ b/packages/backend/src/routes/api/v1/access-tokens.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import createAccessTokenAction from '../../../controllers/api/v1/access-tokens/create-access-token.js'; +import revokeAccessTokenAction from '../../../controllers/api/v1/access-tokens/revoke-access-token.js'; +import { authenticateUser } from '../../../helpers/authentication.js'; +const router = Router(); + +router.post('/', createAccessTokenAction); + +router.delete('/:token', authenticateUser, revokeAccessTokenAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/admin/apps.ee.js b/packages/backend/src/routes/api/v1/admin/apps.ee.js new file mode 100644 index 0000000..6a0eb9a --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/apps.ee.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import createConfigAction from '../../../../controllers/api/v1/admin/apps/create-config.ee.js'; +import updateConfigAction from '../../../../controllers/api/v1/admin/apps/update-config.ee.js'; +import getOAuthClientsAction from '../../../../controllers/api/v1/admin/apps/get-oauth-clients.ee.js'; +import getOAuthClientAction from '../../../../controllers/api/v1/admin/apps/get-oauth-client.ee.js'; +import createOAuthClientAction from '../../../../controllers/api/v1/admin/apps/create-oauth-client.ee.js'; +import updateOAuthClientAction from '../../../../controllers/api/v1/admin/apps/update-oauth-client.ee.js'; + +const router = Router(); + +router.post( + '/:appKey/config', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createConfigAction +); + +router.patch( + '/:appKey/config', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateConfigAction +); + +router.get( + '/:appKey/oauth-clients', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getOAuthClientsAction +); + +router.post( + '/:appKey/oauth-clients', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createOAuthClientAction +); + +router.get( + '/:appKey/oauth-clients/:oauthClientId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getOAuthClientAction +); + +router.patch( + '/:appKey/oauth-clients/:oauthClientId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateOAuthClientAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/admin/config.ee.js b/packages/backend/src/routes/api/v1/admin/config.ee.js new file mode 100644 index 0000000..51b4f25 --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/config.ee.js @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import updateConfigAction from '../../../../controllers/api/v1/admin/config/update.ee.js'; + +const router = Router(); + +router.patch( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateConfigAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/admin/permissions.ee.js b/packages/backend/src/routes/api/v1/admin/permissions.ee.js new file mode 100644 index 0000000..91a8486 --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/permissions.ee.js @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import getPermissionsCatalogAction from '../../../../controllers/api/v1/admin/permissions/get-permissions-catalog.ee.js'; + +const router = Router(); + +router.get( + '/catalog', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getPermissionsCatalogAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/admin/roles.ee.js b/packages/backend/src/routes/api/v1/admin/roles.ee.js new file mode 100644 index 0000000..9715d70 --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/roles.ee.js @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import createRoleAction from '../../../../controllers/api/v1/admin/roles/create-role.ee.js'; +import getRolesAction from '../../../../controllers/api/v1/admin/roles/get-roles.ee.js'; +import getRoleAction from '../../../../controllers/api/v1/admin/roles/get-role.ee.js'; +import updateRoleAction from '../../../../controllers/api/v1/admin/roles/update-role.ee.js'; +import deleteRoleAction from '../../../../controllers/api/v1/admin/roles/delete-role.ee.js'; + +const router = Router(); + +router.post( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createRoleAction +); + +router.get( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getRolesAction +); + +router.get( + '/:roleId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getRoleAction +); + +router.patch( + '/:roleId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateRoleAction +); + +router.delete( + '/:roleId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + deleteRoleAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js b/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js new file mode 100644 index 0000000..eb243b8 --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/saml-auth-providers.ee.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; +import createSamlAuthProviderAction from '../../../../controllers/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js'; +import updateSamlAuthProviderAction from '../../../../controllers/api/v1/admin/saml-auth-providers/update-saml-auth-provider.ee.js'; +import getSamlAuthProvidersAction from '../../../../controllers/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js'; +import getSamlAuthProviderAction from '../../../../controllers/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js'; +import getRoleMappingsAction from '../../../../controllers/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js'; +import updateRoleMappingsAction from '../../../../controllers/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js'; + +const router = Router(); + +router.get( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getSamlAuthProvidersAction +); + +router.post( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createSamlAuthProviderAction +); + +router.get( + '/:samlAuthProviderId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getSamlAuthProviderAction +); + +router.get( + '/:samlAuthProviderId/role-mappings', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + getRoleMappingsAction +); + +router.patch( + '/:samlAuthProviderId', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateSamlAuthProviderAction +); + +router.patch( + '/:samlAuthProviderId/role-mappings', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + updateRoleMappingsAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/admin/users.ee.js b/packages/backend/src/routes/api/v1/admin/users.ee.js new file mode 100644 index 0000000..b42685f --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/users.ee.js @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js'; +import createUserAction from '../../../../controllers/api/v1/admin/users/create-user.js'; +import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js'; +import updateUserAction from '../../../../controllers/api/v1/admin/users/update-user.ee.js'; +import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete-user.js'; + +const router = Router(); + +router.get('/', authenticateUser, authorizeAdmin, getUsersAction); +router.post('/', authenticateUser, authorizeAdmin, createUserAction); +router.get('/:userId', authenticateUser, authorizeAdmin, getUserAction); +router.patch('/:userId', authenticateUser, authorizeAdmin, updateUserAction); +router.delete('/:userId', authenticateUser, authorizeAdmin, deleteUserAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/apps.js b/packages/backend/src/routes/api/v1/apps.js new file mode 100644 index 0000000..c92fc55 --- /dev/null +++ b/packages/backend/src/routes/api/v1/apps.js @@ -0,0 +1,78 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../helpers/check-is-enterprise.js'; +import getAppAction from '../../../controllers/api/v1/apps/get-app.js'; +import getAppsAction from '../../../controllers/api/v1/apps/get-apps.js'; +import getAuthAction from '../../../controllers/api/v1/apps/get-auth.js'; +import getConnectionsAction from '../../../controllers/api/v1/apps/get-connections.js'; +import getConfigAction from '../../../controllers/api/v1/apps/get-config.ee.js'; +import getOAuthClientsAction from '../../../controllers/api/v1/apps/get-oauth-clients.ee.js'; +import getOAuthClientAction from '../../../controllers/api/v1/apps/get-oauth-client.ee.js'; +import getTriggersAction from '../../../controllers/api/v1/apps/get-triggers.js'; +import getTriggerSubstepsAction from '../../../controllers/api/v1/apps/get-trigger-substeps.js'; +import getActionsAction from '../../../controllers/api/v1/apps/get-actions.js'; +import getActionSubstepsAction from '../../../controllers/api/v1/apps/get-action-substeps.js'; +import getFlowsAction from '../../../controllers/api/v1/apps/get-flows.js'; +import createConnectionAction from '../../../controllers/api/v1/apps/create-connection.js'; + +const router = Router(); + +router.get('/', authenticateUser, getAppsAction); +router.get('/:appKey', authenticateUser, getAppAction); +router.get('/:appKey/auth', authenticateUser, getAuthAction); + +router.get( + '/:appKey/connections', + authenticateUser, + authorizeUser, + getConnectionsAction +); + +router.post( + '/:appKey/connections', + authenticateUser, + authorizeUser, + createConnectionAction +); + +router.get( + '/:appKey/config', + authenticateUser, + checkIsEnterprise, + getConfigAction +); + +router.get( + '/:appKey/oauth-clients', + authenticateUser, + checkIsEnterprise, + getOAuthClientsAction +); + +router.get( + '/:appKey/oauth-clients/:oauthClientId', + authenticateUser, + checkIsEnterprise, + getOAuthClientAction +); + +router.get('/:appKey/triggers', authenticateUser, getTriggersAction); + +router.get( + '/:appKey/triggers/:triggerKey/substeps', + authenticateUser, + getTriggerSubstepsAction +); + +router.get('/:appKey/actions', authenticateUser, getActionsAction); + +router.get( + '/:appKey/actions/:actionKey/substeps', + authenticateUser, + getActionSubstepsAction +); + +router.get('/:appKey/flows', authenticateUser, authorizeUser, getFlowsAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/automatisch.js b/packages/backend/src/routes/api/v1/automatisch.js new file mode 100644 index 0000000..ddab831 --- /dev/null +++ b/packages/backend/src/routes/api/v1/automatisch.js @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { checkIsEnterprise } from '../../../helpers/check-is-enterprise.js'; +import versionAction from '../../../controllers/api/v1/automatisch/version.js'; +import notificationsAction from '../../../controllers/api/v1/automatisch/notifications.js'; +import infoAction from '../../../controllers/api/v1/automatisch/info.js'; +import licenseAction from '../../../controllers/api/v1/automatisch/license.js'; +import configAction from '../../../controllers/api/v1/automatisch/config.ee.js'; + +const router = Router(); + +router.get('/version', versionAction); +router.get('/notifications', notificationsAction); +router.get('/info', infoAction); +router.get('/license', licenseAction); +router.get('/config', checkIsEnterprise, configAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/connections.js b/packages/backend/src/routes/api/v1/connections.js new file mode 100644 index 0000000..16e7f73 --- /dev/null +++ b/packages/backend/src/routes/api/v1/connections.js @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import getFlowsAction from '../../../controllers/api/v1/connections/get-flows.js'; +import testConnectionAction from '../../../controllers/api/v1/connections/test-connection.js'; +import verifyConnectionAction from '../../../controllers/api/v1/connections/verify-connection.js'; +import deleteConnectionAction from '../../../controllers/api/v1/connections/delete-connection.js'; +import updateConnectionAction from '../../../controllers/api/v1/connections/update-connection.js'; +import generateAuthUrlAction from '../../../controllers/api/v1/connections/generate-auth-url.js'; +import resetConnectionAction from '../../../controllers/api/v1/connections/reset-connection.js'; + +const router = Router(); + +router.delete( + '/:connectionId', + authenticateUser, + authorizeUser, + deleteConnectionAction +); + +router.patch( + '/:connectionId', + authenticateUser, + authorizeUser, + updateConnectionAction +); + +router.get( + '/:connectionId/flows', + authenticateUser, + authorizeUser, + getFlowsAction +); + +router.post( + '/:connectionId/test', + authenticateUser, + authorizeUser, + testConnectionAction +); + +router.post( + '/:connectionId/reset', + authenticateUser, + authorizeUser, + resetConnectionAction +); + +router.post( + '/:connectionId/auth-url', + authenticateUser, + authorizeUser, + generateAuthUrlAction +); + +router.post( + '/:connectionId/verify', + authenticateUser, + authorizeUser, + verifyConnectionAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/executions.js b/packages/backend/src/routes/api/v1/executions.js new file mode 100644 index 0000000..fdc9871 --- /dev/null +++ b/packages/backend/src/routes/api/v1/executions.js @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import getExecutionsAction from '../../../controllers/api/v1/executions/get-executions.js'; +import getExecutionAction from '../../../controllers/api/v1/executions/get-execution.js'; +import getExecutionStepsAction from '../../../controllers/api/v1/executions/get-execution-steps.js'; + +const router = Router(); + +router.get('/', authenticateUser, authorizeUser, getExecutionsAction); + +router.get( + '/:executionId', + authenticateUser, + authorizeUser, + getExecutionAction +); + +router.get( + '/:executionId/execution-steps', + authenticateUser, + authorizeUser, + getExecutionStepsAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js new file mode 100644 index 0000000..259bd0f --- /dev/null +++ b/packages/backend/src/routes/api/v1/flows.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import getFlowsAction from '../../../controllers/api/v1/flows/get-flows.js'; +import getFlowAction from '../../../controllers/api/v1/flows/get-flow.js'; +import updateFlowAction from '../../../controllers/api/v1/flows/update-flow.js'; +import updateFlowStatusAction from '../../../controllers/api/v1/flows/update-flow-status.js'; +import updateFlowFolderAction from '../../../controllers/api/v1/flows/update-flow-folder.js'; +import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; +import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; +import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; +import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; +import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js'; +import importFlowAction from '../../../controllers/api/v1/flows/import-flow.js'; + +const router = Router(); + +router.get('/', authenticateUser, authorizeUser, getFlowsAction); +router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); +router.post('/', authenticateUser, authorizeUser, createFlowAction); +router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); + +router.patch( + '/:flowId/status', + authenticateUser, + authorizeUser, + updateFlowStatusAction +); + +router.patch( + '/:flowId/folder', + authenticateUser, + authorizeUser, + updateFlowFolderAction +); + +router.post( + '/:flowId/export', + authenticateUser, + authorizeUser, + exportFlowAction +); + +router.post('/import', authenticateUser, authorizeUser, importFlowAction); + +router.post( + '/:flowId/steps', + authenticateUser, + authorizeUser, + createStepAction +); + +router.post( + '/:flowId/duplicate', + authenticateUser, + authorizeUser, + duplicateFlowAction +); + +router.delete('/:flowId', authenticateUser, authorizeUser, deleteFlowAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/folders.js b/packages/backend/src/routes/api/v1/folders.js new file mode 100644 index 0000000..5cc232c --- /dev/null +++ b/packages/backend/src/routes/api/v1/folders.js @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import getFoldersAction from '../../../controllers/api/v1/folders/get-folders.js'; +import createFolderAction from '../../../controllers/api/v1/folders/create-folder.js'; +import updateFolderAction from '../../../controllers/api/v1/folders/update-folder.js'; +import deleteFolderAction from '../../../controllers/api/v1/folders/delete-folder.js'; + +const router = Router(); + +router.get('/', authenticateUser, authorizeUser, getFoldersAction); +router.post('/', authenticateUser, authorizeUser, createFolderAction); +router.patch('/:folderId', authenticateUser, authorizeUser, updateFolderAction); + +router.delete( + '/:folderId', + authenticateUser, + authorizeUser, + deleteFolderAction +); + +export default router; diff --git a/packages/backend/src/routes/api/v1/installation/users.js b/packages/backend/src/routes/api/v1/installation/users.js new file mode 100644 index 0000000..5118b4f --- /dev/null +++ b/packages/backend/src/routes/api/v1/installation/users.js @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { allowInstallation } from '../../../../helpers/allow-installation.js'; +import createUserAction from '../../../../controllers/api/v1/installation/users/create-user.js'; + +const router = Router(); + +router.post('/', allowInstallation, createUserAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/payment.ee.js b/packages/backend/src/routes/api/v1/payment.ee.js new file mode 100644 index 0000000..8f6e42e --- /dev/null +++ b/packages/backend/src/routes/api/v1/payment.ee.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import checkIsCloud from '../../../helpers/check-is-cloud.js'; +import getPlansAction from '../../../controllers/api/v1/payment/get-plans.ee.js'; +import getPaddleInfoAction from '../../../controllers/api/v1/payment/get-paddle-info.ee.js'; + +const router = Router(); + +router.get('/plans', authenticateUser, checkIsCloud, getPlansAction); +router.get('/paddle-info', authenticateUser, checkIsCloud, getPaddleInfoAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/saml-auth-providers.ee.js b/packages/backend/src/routes/api/v1/saml-auth-providers.ee.js new file mode 100644 index 0000000..5cf3fd6 --- /dev/null +++ b/packages/backend/src/routes/api/v1/saml-auth-providers.ee.js @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { checkIsEnterprise } from '../../../helpers/check-is-enterprise.js'; +import getSamlAuthProvidersAction from '../../../controllers/api/v1/saml-auth-providers/get-saml-auth-providers.ee.js'; + +const router = Router(); + +router.get('/', checkIsEnterprise, getSamlAuthProvidersAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/steps.js b/packages/backend/src/routes/api/v1/steps.js new file mode 100644 index 0000000..bcfc807 --- /dev/null +++ b/packages/backend/src/routes/api/v1/steps.js @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import getConnectionAction from '../../../controllers/api/v1/steps/get-connection.js'; +import testStepAction from '../../../controllers/api/v1/steps/test-step.js'; +import getPreviousStepsAction from '../../../controllers/api/v1/steps/get-previous-steps.js'; +import createDynamicFieldsAction from '../../../controllers/api/v1/steps/create-dynamic-fields.js'; +import createDynamicDataAction from '../../../controllers/api/v1/steps/create-dynamic-data.js'; +import deleteStepAction from '../../../controllers/api/v1/steps/delete-step.js'; +import updateStepAction from '../../../controllers/api/v1/steps/update-step.js'; + +const router = Router(); + +router.get( + '/:stepId/connection', + authenticateUser, + authorizeUser, + getConnectionAction +); + +router.post('/:stepId/test', authenticateUser, authorizeUser, testStepAction); + +router.get( + '/:stepId/previous-steps', + authenticateUser, + authorizeUser, + getPreviousStepsAction +); + +router.post( + '/:stepId/dynamic-fields', + authenticateUser, + authorizeUser, + createDynamicFieldsAction +); + +router.post( + '/:stepId/dynamic-data', + authenticateUser, + authorizeUser, + createDynamicDataAction +); + +router.patch('/:stepId', authenticateUser, authorizeUser, updateStepAction); +router.delete('/:stepId', authenticateUser, authorizeUser, deleteStepAction); + +export default router; diff --git a/packages/backend/src/routes/api/v1/users.js b/packages/backend/src/routes/api/v1/users.js new file mode 100644 index 0000000..3619a96 --- /dev/null +++ b/packages/backend/src/routes/api/v1/users.js @@ -0,0 +1,60 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import checkIsCloud from '../../../helpers/check-is-cloud.js'; +import getCurrentUserAction from '../../../controllers/api/v1/users/get-current-user.js'; +import updateCurrentUserAction from '../../../controllers/api/v1/users/update-current-user.js'; +import updateCurrentUserPasswordAction from '../../../controllers/api/v1/users/update-current-user-password.js'; +import deleteCurrentUserAction from '../../../controllers/api/v1/users/delete-current-user.js'; +import getUserTrialAction from '../../../controllers/api/v1/users/get-user-trial.ee.js'; +import getAppsAction from '../../../controllers/api/v1/users/get-apps.js'; +import getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js'; +import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.ee.js'; +import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-and-usage.ee.js'; +import acceptInvitationAction from '../../../controllers/api/v1/users/accept-invitation.js'; +import forgotPasswordAction from '../../../controllers/api/v1/users/forgot-password.js'; +import resetPasswordAction from '../../../controllers/api/v1/users/reset-password.js'; +import registerUserAction from '../../../controllers/api/v1/users/register-user.ee.js'; + +const router = Router(); + +router.get('/me', authenticateUser, getCurrentUserAction); +router.patch('/:userId', authenticateUser, updateCurrentUserAction); + +router.patch( + '/:userId/password', + authenticateUser, + updateCurrentUserPasswordAction +); + +router.get('/:userId/apps', authenticateUser, authorizeUser, getAppsAction); +router.get('/invoices', authenticateUser, checkIsCloud, getInvoicesAction); +router.delete('/:userId', authenticateUser, deleteCurrentUserAction); + +router.get( + '/:userId/trial', + authenticateUser, + checkIsCloud, + getUserTrialAction +); + +router.get( + '/:userId/subscription', + authenticateUser, + checkIsCloud, + getSubscriptionAction +); + +router.get( + '/:userId/plan-and-usage', + authenticateUser, + checkIsCloud, + getPlanAndUsageAction +); + +router.post('/invitation', acceptInvitationAction); +router.post('/forgot-password', forgotPasswordAction); +router.post('/reset-password', resetPasswordAction); +router.post('/register', checkIsCloud, registerUserAction); + +export default router; diff --git a/packages/backend/src/routes/healthcheck.js b/packages/backend/src/routes/healthcheck.js new file mode 100644 index 0000000..eb56d13 --- /dev/null +++ b/packages/backend/src/routes/healthcheck.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import indexAction from '../controllers/healthcheck/index.js'; + +const router = Router(); + +router.get('/', indexAction); + +export default router; diff --git a/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js new file mode 100644 index 0000000..d1841e4 --- /dev/null +++ b/packages/backend/src/routes/index.js @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import webhooksRouter from './webhooks.js'; +import paddleRouter from './paddle.ee.js'; +import healthcheckRouter from './healthcheck.js'; +import automatischRouter from './api/v1/automatisch.js'; +import accessTokensRouter from './api/v1/access-tokens.js'; +import usersRouter from './api/v1/users.js'; +import paymentRouter from './api/v1/payment.ee.js'; +import flowsRouter from './api/v1/flows.js'; +import stepsRouter from './api/v1/steps.js'; +import appsRouter from './api/v1/apps.js'; +import connectionsRouter from './api/v1/connections.js'; +import executionsRouter from './api/v1/executions.js'; +import samlAuthProvidersRouter from './api/v1/saml-auth-providers.ee.js'; +import adminAppsRouter from './api/v1/admin/apps.ee.js'; +import adminConfigRouter from './api/v1/admin/config.ee.js'; +import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.js'; +import rolesRouter from './api/v1/admin/roles.ee.js'; +import permissionsRouter from './api/v1/admin/permissions.ee.js'; +import adminUsersRouter from './api/v1/admin/users.ee.js'; +import installationUsersRouter from './api/v1/installation/users.js'; +import foldersRouter from './api/v1/folders.js'; + +const router = Router(); + +router.use('/webhooks', webhooksRouter); +router.use('/paddle', paddleRouter); +router.use('/healthcheck', healthcheckRouter); +router.use('/api/v1/automatisch', automatischRouter); +router.use('/api/v1/access-tokens', accessTokensRouter); +router.use('/api/v1/users', usersRouter); +router.use('/api/v1/payment', paymentRouter); +router.use('/api/v1/apps', appsRouter); +router.use('/api/v1/connections', connectionsRouter); +router.use('/api/v1/flows', flowsRouter); +router.use('/api/v1/steps', stepsRouter); +router.use('/api/v1/executions', executionsRouter); +router.use('/api/v1/saml-auth-providers', samlAuthProvidersRouter); +router.use('/api/v1/admin/apps', adminAppsRouter); +router.use('/api/v1/admin/config', adminConfigRouter); +router.use('/api/v1/admin/users', adminUsersRouter); +router.use('/api/v1/admin/roles', rolesRouter); +router.use('/api/v1/admin/permissions', permissionsRouter); +router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter); +router.use('/api/v1/installation/users', installationUsersRouter); +router.use('/api/v1/folders', foldersRouter); + +export default router; diff --git a/packages/backend/src/routes/paddle.ee.js b/packages/backend/src/routes/paddle.ee.js new file mode 100644 index 0000000..e4f7b48 --- /dev/null +++ b/packages/backend/src/routes/paddle.ee.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import webhooksHandler from '../controllers/paddle/webhooks.ee.js'; + +const router = Router(); + +router.post('/webhooks', webhooksHandler); + +export default router; diff --git a/packages/backend/src/routes/webhooks.js b/packages/backend/src/routes/webhooks.js new file mode 100644 index 0000000..cd2f359 --- /dev/null +++ b/packages/backend/src/routes/webhooks.js @@ -0,0 +1,45 @@ +import express, { Router } from 'express'; +import multer from 'multer'; + +import appConfig from '../config/app.js'; +import webhookHandlerByFlowId from '../controllers/webhooks/handler-by-flow-id.js'; +import webhookHandlerSyncByFlowId from '../controllers/webhooks/handler-sync-by-flow-id.js'; + +const router = Router(); +const upload = multer(); + +router.use(upload.none()); + +router.use( + express.text({ + limit: appConfig.requestBodySizeLimit, + verify(req, res, buf) { + req.rawBody = buf; + }, + }) +); + +const exposeError = (handler) => async (req, res, next) => { + try { + await handler(req, res, next); + } catch (err) { + next(err); + } +}; + +function createRouteHandler(path, handler) { + const wrappedHandler = exposeError(handler); + + router + .route(path) + .get(wrappedHandler) + .put(wrappedHandler) + .patch(wrappedHandler) + .post(wrappedHandler); +} + +createRouteHandler('/flows/:flowId/sync', webhookHandlerSyncByFlowId); +createRouteHandler('/flows/:flowId', webhookHandlerByFlowId); +createRouteHandler('/:flowId', webhookHandlerByFlowId); + +export default router; diff --git a/packages/backend/src/serializers/action.js b/packages/backend/src/serializers/action.js new file mode 100644 index 0000000..cad8ac5 --- /dev/null +++ b/packages/backend/src/serializers/action.js @@ -0,0 +1,9 @@ +const actionSerializer = (action) => { + return { + name: action.name, + key: action.key, + description: action.description, + }; +}; + +export default actionSerializer; diff --git a/packages/backend/src/serializers/action.test.js b/packages/backend/src/serializers/action.test.js new file mode 100644 index 0000000..ffd792b --- /dev/null +++ b/packages/backend/src/serializers/action.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import App from '../models/app'; +import actionSerializer from './action'; + +describe('actionSerializer', () => { + it('should return the action data', async () => { + const actions = await App.findActionsByKey('github'); + const action = actions[0]; + + const expectedPayload = { + description: action.description, + key: action.key, + name: action.name, + pollInterval: action.pollInterval, + showWebhookUrl: action.showWebhookUrl, + type: action.type, + }; + + expect(expectedPayload).toMatchObject(actionSerializer(action)); + }); +}); diff --git a/packages/backend/src/serializers/admin-saml-auth-provider.ee.js b/packages/backend/src/serializers/admin-saml-auth-provider.ee.js new file mode 100644 index 0000000..37d442a --- /dev/null +++ b/packages/backend/src/serializers/admin-saml-auth-provider.ee.js @@ -0,0 +1,18 @@ +const adminSamlAuthProviderSerializer = (samlAuthProvider) => { + return { + id: samlAuthProvider.id, + name: samlAuthProvider.name, + certificate: samlAuthProvider.certificate, + signatureAlgorithm: samlAuthProvider.signatureAlgorithm, + issuer: samlAuthProvider.issuer, + entryPoint: samlAuthProvider.entryPoint, + firstnameAttributeName: samlAuthProvider.firstnameAttributeName, + surnameAttributeName: samlAuthProvider.surnameAttributeName, + emailAttributeName: samlAuthProvider.emailAttributeName, + roleAttributeName: samlAuthProvider.roleAttributeName, + active: samlAuthProvider.active, + defaultRoleId: samlAuthProvider.defaultRoleId, + }; +}; + +export default adminSamlAuthProviderSerializer; diff --git a/packages/backend/src/serializers/admin-saml-auth-provider.ee.test.js b/packages/backend/src/serializers/admin-saml-auth-provider.ee.test.js new file mode 100644 index 0000000..21fd0e0 --- /dev/null +++ b/packages/backend/src/serializers/admin-saml-auth-provider.ee.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js'; +import adminSamlAuthProviderSerializer from './admin-saml-auth-provider.ee.js'; + +describe('adminSamlAuthProviderSerializer', () => { + let samlAuthProvider; + + beforeEach(async () => { + samlAuthProvider = await createSamlAuthProvider(); + }); + + it('should return saml auth provider data', async () => { + const expectedPayload = { + id: samlAuthProvider.id, + name: samlAuthProvider.name, + certificate: samlAuthProvider.certificate, + signatureAlgorithm: samlAuthProvider.signatureAlgorithm, + issuer: samlAuthProvider.issuer, + entryPoint: samlAuthProvider.entryPoint, + firstnameAttributeName: samlAuthProvider.firstnameAttributeName, + surnameAttributeName: samlAuthProvider.surnameAttributeName, + emailAttributeName: samlAuthProvider.emailAttributeName, + roleAttributeName: samlAuthProvider.roleAttributeName, + active: samlAuthProvider.active, + defaultRoleId: samlAuthProvider.defaultRoleId, + }; + + expect(adminSamlAuthProviderSerializer(samlAuthProvider)).toStrictEqual( + expectedPayload + ); + }); +}); diff --git a/packages/backend/src/serializers/admin/user.js b/packages/backend/src/serializers/admin/user.js new file mode 100644 index 0000000..7620c3f --- /dev/null +++ b/packages/backend/src/serializers/admin/user.js @@ -0,0 +1,11 @@ +import userSerializer from '../user.js'; + +const adminUserSerializer = (user) => { + const userData = userSerializer(user); + + userData.acceptInvitationUrl = user.acceptInvitationUrl; + + return userData; +}; + +export default adminUserSerializer; diff --git a/packages/backend/src/serializers/admin/user.test.js b/packages/backend/src/serializers/admin/user.test.js new file mode 100644 index 0000000..3ef76f6 --- /dev/null +++ b/packages/backend/src/serializers/admin/user.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createUser } from '../../../test/factories/user'; +import adminUserSerializer from './user.js'; + +describe('adminUserSerializer', () => { + let user; + + beforeEach(async () => { + user = await createUser(); + }); + + it('should return user data with accept invitation url', async () => { + const serializedUser = adminUserSerializer(user); + + expect(serializedUser.acceptInvitationUrl).toStrictEqual( + user.acceptInvitationUrl + ); + }); +}); diff --git a/packages/backend/src/serializers/app-config.js b/packages/backend/src/serializers/app-config.js new file mode 100644 index 0000000..8288881 --- /dev/null +++ b/packages/backend/src/serializers/app-config.js @@ -0,0 +1,11 @@ +const appConfigSerializer = (appConfig) => { + return { + key: appConfig.key, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, + disabled: appConfig.disabled, + createdAt: appConfig.createdAt.getTime(), + updatedAt: appConfig.updatedAt.getTime(), + }; +}; + +export default appConfigSerializer; diff --git a/packages/backend/src/serializers/app-config.test.js b/packages/backend/src/serializers/app-config.test.js new file mode 100644 index 0000000..5ccdd02 --- /dev/null +++ b/packages/backend/src/serializers/app-config.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createAppConfig } from '../../test/factories/app-config'; +import appConfigSerializer from './app-config'; + +describe('appConfig serializer', () => { + let appConfig; + + beforeEach(async () => { + appConfig = await createAppConfig(); + }); + + it('should return app config data', async () => { + const expectedPayload = { + key: appConfig.key, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, + disabled: appConfig.disabled, + createdAt: appConfig.createdAt.getTime(), + updatedAt: appConfig.updatedAt.getTime(), + }; + + expect(appConfigSerializer(appConfig)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/app.js b/packages/backend/src/serializers/app.js new file mode 100644 index 0000000..2c8adb9 --- /dev/null +++ b/packages/backend/src/serializers/app.js @@ -0,0 +1,23 @@ +const appSerializer = (app) => { + let appData = { + key: app.key, + name: app.name, + iconUrl: app.iconUrl, + primaryColor: app.primaryColor, + authDocUrl: app.authDocUrl, + supportsConnections: app.supportsConnections, + supportsOauthClients: app?.auth?.generateAuthUrl ? true : false, + }; + + if (app.connectionCount) { + appData.connectionCount = app.connectionCount; + } + + if (app.flowCount) { + appData.flowCount = app.flowCount; + } + + return appData; +}; + +export default appSerializer; diff --git a/packages/backend/src/serializers/app.test.js b/packages/backend/src/serializers/app.test.js new file mode 100644 index 0000000..513792e --- /dev/null +++ b/packages/backend/src/serializers/app.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import App from '../models/app'; +import appSerializer from './app'; + +describe('appSerializer', () => { + it('should return app data', async () => { + const app = await App.findOneByKey('deepl'); + + const expectedPayload = { + name: app.name, + key: app.key, + iconUrl: app.iconUrl, + authDocUrl: app.authDocUrl, + supportsConnections: app.supportsConnections, + supportsOauthClients: app.auth.generateAuthUrl ? true : false, + primaryColor: app.primaryColor, + }; + + expect(appSerializer(app)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/auth.js b/packages/backend/src/serializers/auth.js new file mode 100644 index 0000000..da942e6 --- /dev/null +++ b/packages/backend/src/serializers/auth.js @@ -0,0 +1,11 @@ +const authSerializer = (auth) => { + return { + fields: auth.fields, + authenticationSteps: auth.authenticationSteps, + sharedAuthenticationSteps: auth.sharedAuthenticationSteps, + reconnectionSteps: auth.reconnectionSteps, + sharedReconnectionSteps: auth.sharedReconnectionSteps, + }; +}; + +export default authSerializer; diff --git a/packages/backend/src/serializers/auth.test.js b/packages/backend/src/serializers/auth.test.js new file mode 100644 index 0000000..ef2d1bd --- /dev/null +++ b/packages/backend/src/serializers/auth.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import App from '../models/app'; +import authSerializer from './auth'; + +describe('authSerializer', () => { + it('should return auth data', async () => { + const auth = await App.findAuthByKey('deepl'); + + const expectedPayload = { + fields: auth.fields, + authenticationSteps: auth.authenticationSteps, + reconnectionSteps: auth.reconnectionSteps, + sharedAuthenticationSteps: auth.sharedAuthenticationSteps, + sharedReconnectionSteps: auth.sharedReconnectionSteps, + }; + + expect(authSerializer(auth)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/config.js b/packages/backend/src/serializers/config.js new file mode 100644 index 0000000..6625a1c --- /dev/null +++ b/packages/backend/src/serializers/config.js @@ -0,0 +1,20 @@ +const configSerializer = (config) => { + return { + id: config.id, + updatedAt: config.updatedAt.getTime(), + createdAt: config.createdAt.getTime(), + disableFavicon: config.disableFavicon, + disableNotificationsPage: config.disableNotificationsPage, + additionalDrawerLink: config.additionalDrawerLink, + additionalDrawerLinkIcon: config.additionalDrawerLinkIcon, + additionalDrawerLinkText: config.additionalDrawerLinkText, + logoSvgData: config.logoSvgData, + palettePrimaryDark: config.palettePrimaryDark, + palettePrimaryMain: config.palettePrimaryMain, + palettePrimaryLight: config.palettePrimaryLight, + installationCompleted: config.installationCompleted, + title: config.title, + }; +}; + +export default configSerializer; diff --git a/packages/backend/src/serializers/config.test.js b/packages/backend/src/serializers/config.test.js new file mode 100644 index 0000000..d3339bf --- /dev/null +++ b/packages/backend/src/serializers/config.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getConfig } from '../../test/factories/config'; +import configSerializer from './config'; + +describe('configSerializer', () => { + let config; + + beforeEach(async () => { + config = await getConfig(); + }); + + it('should return config data', async () => { + const expectedPayload = { + id: config.id, + disableFavicon: config.disableFavicon, + disableNotificationsPage: config.disableNotificationsPage, + logoSvgData: config.logoSvgData, + palettePrimaryDark: config.palettePrimaryDark, + palettePrimaryMain: config.palettePrimaryMain, + palettePrimaryLight: config.palettePrimaryLight, + installationCompleted: config.installationCompleted, + title: config.title, + additionalDrawerLink: config.additionalDrawerLink, + additionalDrawerLinkIcon: config.additionalDrawerLinkIcon, + additionalDrawerLinkText: config.additionalDrawerLinkText, + createdAt: config.createdAt.getTime(), + updatedAt: config.updatedAt.getTime(), + }; + + expect(configSerializer(config)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/connection.js b/packages/backend/src/serializers/connection.js new file mode 100644 index 0000000..7022447 --- /dev/null +++ b/packages/backend/src/serializers/connection.js @@ -0,0 +1,15 @@ +const connectionSerializer = (connection) => { + return { + id: connection.id, + key: connection.key, + oauthClientId: connection.oauthClientId, + formattedData: { + screenName: connection.formattedData.screenName, + }, + verified: connection.verified, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; +}; + +export default connectionSerializer; diff --git a/packages/backend/src/serializers/connection.test.js b/packages/backend/src/serializers/connection.test.js new file mode 100644 index 0000000..bb9db58 --- /dev/null +++ b/packages/backend/src/serializers/connection.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createConnection } from '../../test/factories/connection'; +import connectionSerializer from './connection'; + +describe('connectionSerializer', () => { + let connection; + + beforeEach(async () => { + connection = await createConnection(); + }); + + it('should return connection data', async () => { + const expectedPayload = { + id: connection.id, + key: connection.key, + oauthClientId: connection.oauthClientId, + formattedData: { + screenName: connection.formattedData.screenName, + }, + verified: connection.verified, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; + + expect(connectionSerializer(connection)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/execution-step.js b/packages/backend/src/serializers/execution-step.js new file mode 100644 index 0000000..52d7a40 --- /dev/null +++ b/packages/backend/src/serializers/execution-step.js @@ -0,0 +1,21 @@ +import stepSerializer from './step.js'; + +const executionStepSerializer = (executionStep) => { + let executionStepData = { + id: executionStep.id, + dataIn: executionStep.dataIn, + dataOut: executionStep.dataOut, + errorDetails: executionStep.errorDetails, + status: executionStep.status, + createdAt: executionStep.createdAt.getTime(), + updatedAt: executionStep.updatedAt.getTime(), + }; + + if (executionStep.step) { + executionStepData.step = stepSerializer(executionStep.step); + } + + return executionStepData; +}; + +export default executionStepSerializer; diff --git a/packages/backend/src/serializers/execution-step.test.js b/packages/backend/src/serializers/execution-step.test.js new file mode 100644 index 0000000..6c22131 --- /dev/null +++ b/packages/backend/src/serializers/execution-step.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import executionStepSerializer from './execution-step'; +import stepSerializer from './step'; +import { createExecutionStep } from '../../test/factories/execution-step'; +import { createStep } from '../../test/factories/step'; + +describe('executionStepSerializer', () => { + let executionStep, step; + + beforeEach(async () => { + step = await createStep(); + + executionStep = await createExecutionStep({ + stepId: step.id, + }); + }); + + it('should return the execution step data', async () => { + const expectedPayload = { + id: executionStep.id, + dataIn: executionStep.dataIn, + dataOut: executionStep.dataOut, + errorDetails: executionStep.errorDetails, + status: executionStep.status, + createdAt: executionStep.createdAt.getTime(), + updatedAt: executionStep.updatedAt.getTime(), + }; + + expect(executionStepSerializer(executionStep)).toStrictEqual( + expectedPayload + ); + }); + + it('should return the execution step data with the step', async () => { + executionStep.step = step; + + const expectedPayload = { + step: stepSerializer(step), + }; + + expect(executionStepSerializer(executionStep)).toMatchObject( + expectedPayload + ); + }); +}); diff --git a/packages/backend/src/serializers/execution.js b/packages/backend/src/serializers/execution.js new file mode 100644 index 0000000..db57d15 --- /dev/null +++ b/packages/backend/src/serializers/execution.js @@ -0,0 +1,22 @@ +import flowSerializer from './flow.js'; + +const executionSerializer = (execution) => { + let executionData = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + }; + + if (execution.status) { + executionData.status = execution.status; + } + + if (execution.flow) { + executionData.flow = flowSerializer(execution.flow); + } + + return executionData; +}; + +export default executionSerializer; diff --git a/packages/backend/src/serializers/execution.test.js b/packages/backend/src/serializers/execution.test.js new file mode 100644 index 0000000..0e28e77 --- /dev/null +++ b/packages/backend/src/serializers/execution.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import executionSerializer from './execution'; +import flowSerializer from './flow'; +import { createExecution } from '../../test/factories/execution'; +import { createFlow } from '../../test/factories/flow'; + +describe('executionSerializer', () => { + let flow, execution; + + beforeEach(async () => { + flow = await createFlow(); + + execution = await createExecution({ + flowId: flow.id, + }); + }); + + it('should return the execution data', async () => { + const expectedPayload = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + }; + + expect(executionSerializer(execution)).toStrictEqual(expectedPayload); + }); + + it('should return the execution data with status', async () => { + execution.status = 'success'; + + const expectedPayload = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + status: 'success', + }; + + expect(executionSerializer(execution)).toStrictEqual(expectedPayload); + }); + + it('should return the execution data with the flow', async () => { + execution.flow = flow; + + const expectedPayload = { + flow: flowSerializer(flow), + }; + + expect(executionSerializer(execution)).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/flow.js b/packages/backend/src/serializers/flow.js new file mode 100644 index 0000000..2847564 --- /dev/null +++ b/packages/backend/src/serializers/flow.js @@ -0,0 +1,25 @@ +import stepSerializer from './step.js'; +import folderSerilializer from './folder.js'; + +const flowSerializer = (flow) => { + let flowData = { + id: flow.id, + name: flow.name, + active: flow.active, + status: flow.status, + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + if (flow.steps?.length > 0) { + flowData.steps = flow.steps.map((step) => stepSerializer(step)); + } + + if (flow.folder) { + flowData.folder = folderSerilializer(flow.folder); + } + + return flowData; +}; + +export default flowSerializer; diff --git a/packages/backend/src/serializers/flow.test.js b/packages/backend/src/serializers/flow.test.js new file mode 100644 index 0000000..6c185dd --- /dev/null +++ b/packages/backend/src/serializers/flow.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createFlow } from '../../test/factories/flow'; +import flowSerializer from './flow'; +import stepSerializer from './step'; +import { createStep } from '../../test/factories/step'; + +describe('flowSerializer', () => { + let flow, stepOne, stepTwo; + + beforeEach(async () => { + flow = await createFlow(); + + stepOne = await createStep({ + flowId: flow.id, + type: 'trigger', + }); + + stepTwo = await createStep({ + flowId: flow.id, + type: 'action', + }); + }); + + it('should return flow data', async () => { + const expectedPayload = { + id: flow.id, + name: flow.name, + active: flow.active, + status: flow.status, + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + expect(flowSerializer(flow)).toStrictEqual(expectedPayload); + }); + + it('should return flow data with the steps', async () => { + flow.steps = [stepOne, stepTwo]; + + const expectedPayload = { + steps: [stepSerializer(stepOne), stepSerializer(stepTwo)], + }; + + expect(flowSerializer(flow)).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/folder.js b/packages/backend/src/serializers/folder.js new file mode 100644 index 0000000..9c0ae29 --- /dev/null +++ b/packages/backend/src/serializers/folder.js @@ -0,0 +1,10 @@ +const folderSerilializer = (folder) => { + return { + id: folder.id, + name: folder.name, + createdAt: folder.createdAt.getTime(), + updatedAt: folder.updatedAt.getTime(), + }; +}; + +export default folderSerilializer; diff --git a/packages/backend/src/serializers/folder.test.js b/packages/backend/src/serializers/folder.test.js new file mode 100644 index 0000000..155c0bd --- /dev/null +++ b/packages/backend/src/serializers/folder.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createFolder } from '../../test/factories/folder'; +import folderSerializer from './folder'; + +describe('folder serializer', () => { + let folder; + + beforeEach(async () => { + folder = await createFolder(); + }); + + it('should return folder data', async () => { + const expectedPayload = { + id: folder.id, + name: folder.name, + createdAt: folder.createdAt.getTime(), + updatedAt: folder.updatedAt.getTime(), + }; + + expect(folderSerializer(folder)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/index.js b/packages/backend/src/serializers/index.js new file mode 100644 index 0000000..ba53399 --- /dev/null +++ b/packages/backend/src/serializers/index.js @@ -0,0 +1,49 @@ +import userSerializer from './user.js'; +import roleSerializer from './role.js'; +import permissionSerializer from './permission.js'; +import adminSamlAuthProviderSerializer from './admin-saml-auth-provider.ee.js'; +import samlAuthProviderSerializer from './saml-auth-provider.ee.js'; +import samlAuthProviderRoleMappingSerializer from './role-mapping.ee.js'; +import oauthClientSerializer from './oauth-client.js'; +import appConfigSerializer from './app-config.js'; +import flowSerializer from './flow.js'; +import stepSerializer from './step.js'; +import connectionSerializer from './connection.js'; +import appSerializer from './app.js'; +import userAppSerializer from './user-app.js'; +import authSerializer from './auth.js'; +import triggerSerializer from './trigger.js'; +import actionSerializer from './action.js'; +import executionSerializer from './execution.js'; +import executionStepSerializer from './execution-step.js'; +import subscriptionSerializer from './subscription.ee.js'; +import adminUserSerializer from './admin/user.js'; +import configSerializer from './config.js'; +import folderSerializer from './folder.js'; + +const serializers = { + AdminUser: adminUserSerializer, + User: userSerializer, + Role: roleSerializer, + Permission: permissionSerializer, + AdminSamlAuthProvider: adminSamlAuthProviderSerializer, + SamlAuthProvider: samlAuthProviderSerializer, + RoleMapping: samlAuthProviderRoleMappingSerializer, + OAuthClient: oauthClientSerializer, + AppConfig: appConfigSerializer, + Flow: flowSerializer, + Step: stepSerializer, + Connection: connectionSerializer, + App: appSerializer, + UserApp: userAppSerializer, + Auth: authSerializer, + Trigger: triggerSerializer, + Action: actionSerializer, + Execution: executionSerializer, + ExecutionStep: executionStepSerializer, + Subscription: subscriptionSerializer, + Config: configSerializer, + Folder: folderSerializer, +}; + +export default serializers; diff --git a/packages/backend/src/serializers/oauth-client.js b/packages/backend/src/serializers/oauth-client.js new file mode 100644 index 0000000..bacebaf --- /dev/null +++ b/packages/backend/src/serializers/oauth-client.js @@ -0,0 +1,10 @@ +const oauthClientSerializer = (oauthClient) => { + return { + id: oauthClient.id, + appConfigId: oauthClient.appConfigId, + name: oauthClient.name, + active: oauthClient.active, + }; +}; + +export default oauthClientSerializer; diff --git a/packages/backend/src/serializers/oauth-client.test.js b/packages/backend/src/serializers/oauth-client.test.js new file mode 100644 index 0000000..d5ab8d7 --- /dev/null +++ b/packages/backend/src/serializers/oauth-client.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createOAuthClient } from '../../test/factories/oauth-client'; +import oauthClientSerializer from './oauth-client'; + +describe('oauthClient serializer', () => { + let oauthClient; + + beforeEach(async () => { + oauthClient = await createOAuthClient(); + }); + + it('should return oauth client data', async () => { + const expectedPayload = { + id: oauthClient.id, + appConfigId: oauthClient.appConfigId, + name: oauthClient.name, + active: oauthClient.active, + }; + + expect(oauthClientSerializer(oauthClient)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/permission.js b/packages/backend/src/serializers/permission.js new file mode 100644 index 0000000..07b8202 --- /dev/null +++ b/packages/backend/src/serializers/permission.js @@ -0,0 +1,13 @@ +const permissionSerializer = (permission) => { + return { + id: permission.id, + roleId: permission.roleId, + action: permission.action, + subject: permission.subject, + conditions: permission.conditions, + createdAt: permission.createdAt.getTime(), + updatedAt: permission.updatedAt.getTime(), + }; +}; + +export default permissionSerializer; diff --git a/packages/backend/src/serializers/permission.test.js b/packages/backend/src/serializers/permission.test.js new file mode 100644 index 0000000..73c0055 --- /dev/null +++ b/packages/backend/src/serializers/permission.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPermission } from '../../test/factories/permission'; +import permissionSerializer from './permission'; + +describe('permissionSerializer', () => { + let permission; + + beforeEach(async () => { + permission = await createPermission(); + }); + + it('should return permission data', async () => { + const expectedPayload = { + id: permission.id, + roleId: permission.roleId, + action: permission.action, + subject: permission.subject, + conditions: permission.conditions, + createdAt: permission.createdAt.getTime(), + updatedAt: permission.updatedAt.getTime(), + }; + + expect(permissionSerializer(permission)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/role-mapping.ee.js b/packages/backend/src/serializers/role-mapping.ee.js new file mode 100644 index 0000000..cb2f9ed --- /dev/null +++ b/packages/backend/src/serializers/role-mapping.ee.js @@ -0,0 +1,10 @@ +const roleMappingSerializer = (roleMapping) => { + return { + id: roleMapping.id, + samlAuthProviderId: roleMapping.samlAuthProviderId, + roleId: roleMapping.roleId, + remoteRoleName: roleMapping.remoteRoleName, + }; +}; + +export default roleMappingSerializer; diff --git a/packages/backend/src/serializers/role.js b/packages/backend/src/serializers/role.js new file mode 100644 index 0000000..8e9051f --- /dev/null +++ b/packages/backend/src/serializers/role.js @@ -0,0 +1,23 @@ +import permissionSerializer from './permission.js'; + +const roleSerializer = (role) => { + let roleData = { + id: role.id, + name: role.name, + key: role.key, + description: role.description, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + isAdmin: role.isAdmin, + }; + + if (role.permissions?.length > 0) { + roleData.permissions = role.permissions.map((permission) => + permissionSerializer(permission) + ); + } + + return roleData; +}; + +export default roleSerializer; diff --git a/packages/backend/src/serializers/role.test.js b/packages/backend/src/serializers/role.test.js new file mode 100644 index 0000000..f415c82 --- /dev/null +++ b/packages/backend/src/serializers/role.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRole } from '../../test/factories/role'; +import roleSerializer from './role'; +import permissionSerializer from './permission'; +import { createPermission } from '../../test/factories/permission'; + +describe('roleSerializer', () => { + let role, permissionOne, permissionTwo; + + beforeEach(async () => { + role = await createRole(); + + permissionOne = await createPermission({ + roleId: role.id, + action: 'read', + subject: 'User', + }); + + permissionTwo = await createPermission({ + roleId: role.id, + action: 'read', + subject: 'Role', + }); + }); + + it('should return role data', async () => { + const expectedPayload = { + id: role.id, + name: role.name, + key: role.key, + description: role.description, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + isAdmin: role.isAdmin, + }; + + expect(roleSerializer(role)).toStrictEqual(expectedPayload); + }); + + it('should return role data with the permissions', async () => { + role.permissions = [permissionOne, permissionTwo]; + + const expectedPayload = { + permissions: [ + permissionSerializer(permissionOne), + permissionSerializer(permissionTwo), + ], + }; + + expect(roleSerializer(role)).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/saml-auth-provider.ee.js b/packages/backend/src/serializers/saml-auth-provider.ee.js new file mode 100644 index 0000000..4c0bfde --- /dev/null +++ b/packages/backend/src/serializers/saml-auth-provider.ee.js @@ -0,0 +1,10 @@ +const samlAuthProviderSerializer = (samlAuthProvider) => { + return { + id: samlAuthProvider.id, + name: samlAuthProvider.name, + loginUrl: samlAuthProvider.loginUrl, + issuer: samlAuthProvider.issuer, + }; +}; + +export default samlAuthProviderSerializer; diff --git a/packages/backend/src/serializers/saml-auth-provider.ee.test.js b/packages/backend/src/serializers/saml-auth-provider.ee.test.js new file mode 100644 index 0000000..ca1da1a --- /dev/null +++ b/packages/backend/src/serializers/saml-auth-provider.ee.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js'; +import samlAuthProviderSerializer from './saml-auth-provider.ee.js'; + +describe('samlAuthProviderSerializer', () => { + let samlAuthProvider; + + beforeEach(async () => { + samlAuthProvider = await createSamlAuthProvider(); + }); + + it('should return saml auth provider data', async () => { + const expectedPayload = { + id: samlAuthProvider.id, + name: samlAuthProvider.name, + loginUrl: samlAuthProvider.loginUrl, + issuer: samlAuthProvider.issuer, + }; + + expect(samlAuthProviderSerializer(samlAuthProvider)).toStrictEqual( + expectedPayload + ); + }); +}); diff --git a/packages/backend/src/serializers/step.js b/packages/backend/src/serializers/step.js new file mode 100644 index 0000000..5c1e0d3 --- /dev/null +++ b/packages/backend/src/serializers/step.js @@ -0,0 +1,32 @@ +import executionStepSerializer from './execution-step.js'; + +const stepSerializer = (step) => { + let stepData = { + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + }; + + if (step.lastExecutionStep) { + stepData.lastExecutionStep = executionStepSerializer( + step.lastExecutionStep + ); + } + + if (step.executionSteps?.length > 0) { + stepData.executionSteps = step.executionSteps.map((executionStep) => + executionStepSerializer(executionStep) + ); + } + + return stepData; +}; + +export default stepSerializer; diff --git a/packages/backend/src/serializers/step.test.js b/packages/backend/src/serializers/step.test.js new file mode 100644 index 0000000..2b26cfc --- /dev/null +++ b/packages/backend/src/serializers/step.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createStep } from '../../test/factories/step'; +import { createExecutionStep } from '../../test/factories/execution-step'; +import stepSerializer from './step'; +import executionStepSerializer from './execution-step'; + +describe('stepSerializer', () => { + let step; + + beforeEach(async () => { + step = await createStep(); + }); + + it('should return step data', async () => { + const expectedPayload = { + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + }; + + expect(stepSerializer(step)).toStrictEqual(expectedPayload); + }); + + it('should return step data with the last execution step', async () => { + const executionStep = await createExecutionStep({ stepId: step.id }); + + step.lastExecutionStep = executionStep; + + const expectedPayload = { + lastExecutionStep: executionStepSerializer(executionStep), + }; + + expect(stepSerializer(step)).toMatchObject(expectedPayload); + }); + + it('should return step data with the execution steps', async () => { + const executionStepOne = await createExecutionStep({ stepId: step.id }); + const executionStepTwo = await createExecutionStep({ stepId: step.id }); + + step.executionSteps = [executionStepOne, executionStepTwo]; + + const expectedPayload = { + executionSteps: [ + executionStepSerializer(executionStepOne), + executionStepSerializer(executionStepTwo), + ], + }; + + expect(stepSerializer(step)).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/subscription.ee.js b/packages/backend/src/serializers/subscription.ee.js new file mode 100644 index 0000000..0e2e523 --- /dev/null +++ b/packages/backend/src/serializers/subscription.ee.js @@ -0,0 +1,20 @@ +const subscriptinSerializer = (subscription) => { + let userData = { + id: subscription.id, + paddleSubscriptionId: subscription.paddleSubscriptionId, + paddlePlanId: subscription.paddlePlanId, + updateUrl: subscription.updateUrl, + cancelUrl: subscription.cancelUrl, + status: subscription.status, + nextBillAmount: subscription.nextBillAmount, + nextBillDate: subscription.nextBillDate, + lastBillDate: subscription.lastBillDate, + createdAt: subscription.createdAt.getTime(), + updatedAt: subscription.updatedAt.getTime(), + cancellationEffectiveDate: subscription.cancellationEffectiveDate, + }; + + return userData; +}; + +export default subscriptinSerializer; diff --git a/packages/backend/src/serializers/subscription.ee.test.js b/packages/backend/src/serializers/subscription.ee.test.js new file mode 100644 index 0000000..3441051 --- /dev/null +++ b/packages/backend/src/serializers/subscription.ee.test.js @@ -0,0 +1,35 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import appConfig from '../config/app'; +import { createUser } from '../../test/factories/user'; +import { createSubscription } from '../../test/factories/subscription'; +import subscriptionSerializer from './subscription.ee.js'; + +describe('subscriptionSerializer', () => { + let user, subscription; + + beforeEach(async () => { + user = await createUser(); + subscription = await createSubscription({ userId: user.id }); + }); + + it('should return subscription data', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + + const expectedPayload = { + id: subscription.id, + paddleSubscriptionId: subscription.paddleSubscriptionId, + paddlePlanId: subscription.paddlePlanId, + updateUrl: subscription.updateUrl, + cancelUrl: subscription.cancelUrl, + status: subscription.status, + nextBillAmount: subscription.nextBillAmount, + nextBillDate: subscription.nextBillDate, + lastBillDate: subscription.lastBillDate, + createdAt: subscription.createdAt.getTime(), + updatedAt: subscription.updatedAt.getTime(), + cancellationEffectiveDate: subscription.cancellationEffectiveDate, + }; + + expect(subscriptionSerializer(subscription)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/trigger.js b/packages/backend/src/serializers/trigger.js new file mode 100644 index 0000000..07fc092 --- /dev/null +++ b/packages/backend/src/serializers/trigger.js @@ -0,0 +1,12 @@ +const triggerSerializer = (trigger) => { + return { + description: trigger.description, + key: trigger.key, + name: trigger.name, + pollInterval: trigger.pollInterval, + showWebhookUrl: trigger.showWebhookUrl, + type: trigger.type, + }; +}; + +export default triggerSerializer; diff --git a/packages/backend/src/serializers/trigger.test.js b/packages/backend/src/serializers/trigger.test.js new file mode 100644 index 0000000..531e797 --- /dev/null +++ b/packages/backend/src/serializers/trigger.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import App from '../models/app'; +import triggerSerializer from './trigger'; + +describe('triggerSerializer', () => { + it('should return the trigger data', async () => { + const triggers = await App.findTriggersByKey('github'); + const trigger = triggers[0]; + + const expectedPayload = { + description: trigger.description, + key: trigger.key, + name: trigger.name, + pollInterval: trigger.pollInterval, + showWebhookUrl: trigger.showWebhookUrl, + type: trigger.type, + }; + + expect(triggerSerializer(trigger)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/user-app.js b/packages/backend/src/serializers/user-app.js new file mode 100644 index 0000000..0d16865 --- /dev/null +++ b/packages/backend/src/serializers/user-app.js @@ -0,0 +1,22 @@ +const userAppSerializer = (userApp) => { + let appData = { + key: userApp.key, + name: userApp.name, + iconUrl: userApp.iconUrl, + primaryColor: userApp.primaryColor, + authDocUrl: userApp.authDocUrl, + supportsConnections: userApp.supportsConnections, + }; + + if (userApp.connectionCount) { + appData.connectionCount = userApp.connectionCount; + } + + if (userApp.flowCount) { + appData.flowCount = userApp.flowCount; + } + + return appData; +}; + +export default userAppSerializer; diff --git a/packages/backend/src/serializers/user.js b/packages/backend/src/serializers/user.js new file mode 100644 index 0000000..d6a147d --- /dev/null +++ b/packages/backend/src/serializers/user.js @@ -0,0 +1,32 @@ +import roleSerializer from './role.js'; +import permissionSerializer from './permission.js'; +import appConfig from '../config/app.js'; + +const userSerializer = (user) => { + let userData = { + id: user.id, + email: user.email, + createdAt: user.createdAt.getTime(), + updatedAt: user.updatedAt.getTime(), + status: user.status, + fullName: user.fullName, + }; + + if (user.role) { + userData.role = roleSerializer(user.role); + } + + if (user.permissions?.length > 0) { + userData.permissions = user.permissions.map((permission) => + permissionSerializer(permission) + ); + } + + if (appConfig.isCloud && user.trialExpiryDate) { + userData.trialExpiryDate = user.trialExpiryDate; + } + + return userData; +}; + +export default userSerializer; diff --git a/packages/backend/src/serializers/user.test.js b/packages/backend/src/serializers/user.test.js new file mode 100644 index 0000000..6908abd --- /dev/null +++ b/packages/backend/src/serializers/user.test.js @@ -0,0 +1,81 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { DateTime } from 'luxon'; +import appConfig from '../config/app'; +import { createUser } from '../../test/factories/user'; +import { createPermission } from '../../test/factories/permission'; +import userSerializer from './user'; +import roleSerializer from './role'; +import permissionSerializer from './permission'; + +describe('userSerializer', () => { + let user, role, permissionOne, permissionTwo; + + beforeEach(async () => { + user = await createUser(); + role = await user.$relatedQuery('role'); + + permissionOne = await createPermission({ + roleId: role.id, + action: 'read', + subject: 'User', + }); + + permissionTwo = await createPermission({ + roleId: role.id, + action: 'read', + subject: 'Role', + }); + }); + + it('should return user data', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false); + + const expectedPayload = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + }; + + expect(userSerializer(user)).toStrictEqual(expectedPayload); + }); + + it('should return user data with the role', async () => { + user.role = role; + + const expectedPayload = { + role: roleSerializer(role), + }; + + expect(userSerializer(user)).toMatchObject(expectedPayload); + }); + + it('should return user data with the permissions', async () => { + user.permissions = [permissionOne, permissionTwo]; + + const expectedPayload = { + permissions: [ + permissionSerializer(permissionOne), + permissionSerializer(permissionTwo), + ], + }; + + expect(userSerializer(user)).toMatchObject(expectedPayload); + }); + + it('should return user data with trial expiry date', async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + + await user.$query().patchAndFetch({ + trialExpiryDate: DateTime.now().plus({ days: 30 }).toISODate(), + }); + + const expectedPayload = { + trialExpiryDate: user.trialExpiryDate, + }; + + expect(userSerializer(user)).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/server.js b/packages/backend/src/server.js new file mode 100644 index 0000000..6396088 --- /dev/null +++ b/packages/backend/src/server.js @@ -0,0 +1,18 @@ +import app from './app.js'; +import appConfig from './config/app.js'; +import logger from './helpers/logger.js'; +import telemetry from './helpers/telemetry/index.js'; + +telemetry.setServiceType('main'); + +const server = app.listen(appConfig.port, () => { + logger.info(`Server is listening on ${appConfig.baseUrl}`); +}); + +function shutdown(server) { + server.close(); +} + +process.on('SIGTERM', () => { + shutdown(server); +}); diff --git a/packages/backend/src/services/action.js b/packages/backend/src/services/action.js new file mode 100644 index 0000000..cb4a6ba --- /dev/null +++ b/packages/backend/src/services/action.js @@ -0,0 +1,79 @@ +import Step from '../models/step.js'; +import Flow from '../models/flow.js'; +import Execution from '../models/execution.js'; +import ExecutionStep from '../models/execution-step.js'; +import computeParameters from '../helpers/compute-parameters.js'; +import globalVariable from '../helpers/global-variable.js'; +import { logger } from '../helpers/logger.js'; +import HttpError from '../errors/http.js'; +import EarlyExitError from '../errors/early-exit.js'; +import AlreadyProcessedError from '../errors/already-processed.js'; + +export const processAction = async (options) => { + const { flowId, stepId, executionId } = options; + + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const execution = await Execution.query() + .findById(executionId) + .throwIfNotFound(); + + const step = await Step.query().findById(stepId).throwIfNotFound(); + + const $ = await globalVariable({ + flow, + app: await step.getApp(), + step: step, + connection: await step.$relatedQuery('connection'), + execution, + }); + + const priorExecutionSteps = await ExecutionStep.query().where({ + execution_id: $.execution.id, + }); + + const stepSetupAndDynamicFields = await step.getSetupAndDynamicFields(); + + const computedParameters = computeParameters( + $.step.parameters, + stepSetupAndDynamicFields, + priorExecutionSteps + ); + + const actionCommand = await step.getActionCommand(); + + $.step.parameters = computedParameters; + + try { + await actionCommand.run($); + } catch (error) { + const shouldEarlyExit = error instanceof EarlyExitError; + const shouldNotProcess = error instanceof AlreadyProcessedError; + const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess; + + if (!shouldNotConsiderAsError) { + if (error instanceof HttpError) { + $.actionOutput.error = error.details; + } else { + try { + $.actionOutput.error = JSON.parse(error.message); + } catch { + $.actionOutput.error = { error: error.message }; + } + } + + logger.error(error); + } + } + + const executionStep = await execution + .$relatedQuery('executionSteps') + .insertAndFetch({ + stepId: $.step.id, + status: $.actionOutput.error ? 'failure' : 'success', + dataIn: computedParameters, + dataOut: $.actionOutput.error ? null : $.actionOutput.data?.raw, + errorDetails: $.actionOutput.error ? $.actionOutput.error : null, + }); + + return { flowId, stepId, executionId, executionStep, computedParameters }; +}; diff --git a/packages/backend/src/services/flow.js b/packages/backend/src/services/flow.js new file mode 100644 index 0000000..a384c1c --- /dev/null +++ b/packages/backend/src/services/flow.js @@ -0,0 +1,49 @@ +import Flow from '../models/flow.js'; +import globalVariable from '../helpers/global-variable.js'; +import EarlyExitError from '../errors/early-exit.js'; +import AlreadyProcessedError from '../errors/already-processed.js'; +import HttpError from '../errors/http.js'; +import { logger } from '../helpers/logger.js'; + +export const processFlow = async (options) => { + const { testRun, flowId } = options; + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const triggerStep = await flow.getTriggerStep(); + const triggerCommand = await triggerStep.getTriggerCommand(); + + const $ = await globalVariable({ + flow, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + testRun, + }); + + try { + if (triggerCommand.type === 'webhook' && !flow.active) { + await triggerCommand.testRun($); + } else { + await triggerCommand.run($); + } + } catch (error) { + const shouldEarlyExit = error instanceof EarlyExitError; + const shouldNotProcess = error instanceof AlreadyProcessedError; + const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess; + + if (!shouldNotConsiderAsError) { + if (error instanceof HttpError) { + $.triggerOutput.error = error.details; + } else { + try { + $.triggerOutput.error = JSON.parse(error.message); + } catch { + $.triggerOutput.error = { error: error.message }; + } + } + + logger.error(error); + } + } + + return $.triggerOutput; +}; diff --git a/packages/backend/src/services/test-run.js b/packages/backend/src/services/test-run.js new file mode 100644 index 0000000..8802382 --- /dev/null +++ b/packages/backend/src/services/test-run.js @@ -0,0 +1,61 @@ +import Step from '../models/step.js'; +import { processFlow } from './flow.js'; +import { processTrigger } from './trigger.js'; +import { processAction } from './action.js'; + +const testRun = async (options) => { + const untilStep = await Step.query() + .findById(options.stepId) + .throwIfNotFound(); + + const flow = await untilStep.$relatedQuery('flow'); + const [triggerStep, ...actionSteps] = await flow + .$relatedQuery('steps') + .withGraphFetched('connection') + .orderBy('position', 'asc'); + + const { data, error: triggerError } = await processFlow({ + flowId: flow.id, + testRun: true, + }); + + if (triggerError) { + const { executionStep: triggerExecutionStepWithError } = + await processTrigger({ + flowId: flow.id, + stepId: triggerStep.id, + error: triggerError, + testRun: true, + }); + + return { executionStep: triggerExecutionStepWithError }; + } + + const firstTriggerItem = data[0]; + + const { executionId, executionStep: triggerExecutionStep } = + await processTrigger({ + flowId: flow.id, + stepId: triggerStep.id, + triggerItem: firstTriggerItem, + testRun: true, + }); + + if (triggerStep.id === untilStep.id) { + return { executionStep: triggerExecutionStep }; + } + + for (const actionStep of actionSteps) { + const { executionStep: actionExecutionStep } = await processAction({ + flowId: flow.id, + stepId: actionStep.id, + executionId, + }); + + if (actionStep.id === untilStep.id || actionExecutionStep.isFailed) { + return { executionStep: actionExecutionStep }; + } + } +}; + +export default testRun; diff --git a/packages/backend/src/services/trigger.js b/packages/backend/src/services/trigger.js new file mode 100644 index 0000000..075a9fc --- /dev/null +++ b/packages/backend/src/services/trigger.js @@ -0,0 +1,37 @@ +import Step from '../models/step.js'; +import Flow from '../models/flow.js'; +import Execution from '../models/execution.js'; +import globalVariable from '../helpers/global-variable.js'; + +export const processTrigger = async (options) => { + const { flowId, stepId, triggerItem, error, testRun } = options; + + const step = await Step.query().findById(stepId).throwIfNotFound(); + + const $ = await globalVariable({ + flow: await Flow.query().findById(flowId).throwIfNotFound(), + app: await step.getApp(), + step: step, + connection: await step.$relatedQuery('connection'), + }); + + // check if we already process this trigger data item or not! + + const execution = await Execution.query().insert({ + flowId: $.flow.id, + testRun, + internalId: triggerItem?.meta.internalId, + }); + + const executionStep = await execution + .$relatedQuery('executionSteps') + .insertAndFetch({ + stepId: $.step.id, + status: error ? 'failure' : 'success', + dataIn: $.step.parameters, + dataOut: !error ? triggerItem?.raw : null, + errorDetails: error, + }); + + return { flowId, stepId, executionId: execution.id, executionStep }; +}; diff --git a/packages/backend/src/views/emails/invitation-instructions.hbs b/packages/backend/src/views/emails/invitation-instructions.hbs new file mode 100644 index 0000000..aa7d792 --- /dev/null +++ b/packages/backend/src/views/emails/invitation-instructions.hbs @@ -0,0 +1,23 @@ + + + + Invitation instructions + + +

+ Hello {{ fullName }}, +

+ +

+ You have been invited to join our platform. To accept the invitation, click the link below. +

+ +

+ Accept invitation +

+ +

+ If you did not expect this invitation, you can ignore this email. +

+ + diff --git a/packages/backend/src/views/emails/reset-password-instructions.ee.hbs b/packages/backend/src/views/emails/reset-password-instructions.ee.hbs new file mode 100644 index 0000000..5392cf0 --- /dev/null +++ b/packages/backend/src/views/emails/reset-password-instructions.ee.hbs @@ -0,0 +1,23 @@ + + + + Reset password instructions + + +

+ Hello {{ fullName }}, +

+ +

+ Someone has requested a link to change your password, and you can do this through the link below within 72 hours. +

+ +

+ Change my password +

+ +

+ If you didn't request this, please ignore this email. Your password won't change until you access the link above and create a new one. +

+ + diff --git a/packages/backend/src/worker.js b/packages/backend/src/worker.js new file mode 100644 index 0000000..214e343 --- /dev/null +++ b/packages/backend/src/worker.js @@ -0,0 +1,23 @@ +import * as Sentry from './helpers/sentry.ee.js'; +import process from 'node:process'; + +Sentry.init(); + +import './config/orm.js'; +import './helpers/check-worker-readiness.js'; +import queues from './queues/index.js'; +import workers from './workers/index.js'; + +process.on('SIGTERM', async () => { + for (const queue of queues) { + await queue.close(); + } + + for (const worker of workers) { + await worker.close(); + } +}); + +import telemetry from './helpers/telemetry/index.js'; + +telemetry.setServiceType('worker'); diff --git a/packages/backend/src/workers/action.js b/packages/backend/src/workers/action.js new file mode 100644 index 0000000..b072805 --- /dev/null +++ b/packages/backend/src/workers/action.js @@ -0,0 +1,6 @@ +import { generateWorker } from './worker.js'; +import { executeActionJob } from '../jobs/execute-action.js'; + +const actionWorker = generateWorker('action', executeActionJob); + +export default actionWorker; diff --git a/packages/backend/src/workers/delete-user.ee.js b/packages/backend/src/workers/delete-user.ee.js new file mode 100644 index 0000000..c47093b --- /dev/null +++ b/packages/backend/src/workers/delete-user.ee.js @@ -0,0 +1,6 @@ +import { generateWorker } from './worker.js'; +import { deleteUserJob } from '../jobs/delete-user.ee.js'; + +const deleteUserWorker = generateWorker('delete-user', deleteUserJob); + +export default deleteUserWorker; diff --git a/packages/backend/src/workers/email.js b/packages/backend/src/workers/email.js new file mode 100644 index 0000000..4cd2c1b --- /dev/null +++ b/packages/backend/src/workers/email.js @@ -0,0 +1,6 @@ +import { generateWorker } from './worker.js'; +import { sendEmailJob } from '../jobs/send-email.js'; + +const emailWorker = generateWorker('email', sendEmailJob); + +export default emailWorker; diff --git a/packages/backend/src/workers/flow.js b/packages/backend/src/workers/flow.js new file mode 100644 index 0000000..7b04bef --- /dev/null +++ b/packages/backend/src/workers/flow.js @@ -0,0 +1,6 @@ +import { generateWorker } from './worker.js'; +import { executeFlowJob } from '../jobs/execute-flow.js'; + +const flowWorker = generateWorker('flow', executeFlowJob); + +export default flowWorker; diff --git a/packages/backend/src/workers/index.js b/packages/backend/src/workers/index.js new file mode 100644 index 0000000..81446f2 --- /dev/null +++ b/packages/backend/src/workers/index.js @@ -0,0 +1,21 @@ +import appConfig from '../config/app.js'; +import actionWorker from './action.js'; +import emailWorker from './email.js'; +import flowWorker from './flow.js'; +import triggerWorker from './trigger.js'; +import deleteUserWorker from './delete-user.ee.js'; +import removeCancelledSubscriptionsWorker from './remove-cancelled-subscriptions.ee.js'; + +const workers = [ + actionWorker, + emailWorker, + flowWorker, + triggerWorker, + deleteUserWorker, +]; + +if (appConfig.isCloud) { + workers.push(removeCancelledSubscriptionsWorker); +} + +export default workers; diff --git a/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js b/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js new file mode 100644 index 0000000..83df086 --- /dev/null +++ b/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js @@ -0,0 +1,9 @@ +import { generateWorker } from './worker.js'; +import { removeCancelledSubscriptionsJob } from '../jobs/remove-cancelled-subscriptions.ee.js'; + +const removeCancelledSubscriptionsWorker = generateWorker( + 'remove-cancelled-subscriptions', + removeCancelledSubscriptionsJob +); + +export default removeCancelledSubscriptionsWorker; diff --git a/packages/backend/src/workers/trigger.js b/packages/backend/src/workers/trigger.js new file mode 100644 index 0000000..5006117 --- /dev/null +++ b/packages/backend/src/workers/trigger.js @@ -0,0 +1,6 @@ +import { generateWorker } from './worker.js'; +import { executeTriggerJob } from '../jobs/execute-trigger.js'; + +const triggerWorker = generateWorker('trigger', executeTriggerJob); + +export default triggerWorker; diff --git a/packages/backend/src/workers/worker.js b/packages/backend/src/workers/worker.js new file mode 100644 index 0000000..5528a24 --- /dev/null +++ b/packages/backend/src/workers/worker.js @@ -0,0 +1,28 @@ +import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee.js'; +import redisConfig from '../config/redis.js'; +import logger from '../helpers/logger.js'; + +export const generateWorker = (workerName, job) => { + const worker = new Worker(workerName, job, { connection: redisConfig }); + + worker.on('completed', (job) => { + logger.info(`JOB ID: ${job.id} - has been successfully completed!`); + }); + + worker.on('failed', (job, err) => { + logger.error(` + JOB ID: ${job.id} - has failed to be completed! ${err.message} + \n ${err.stack} + `); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + }, + }); + }); + + return worker; +}; diff --git a/packages/backend/test/assertions/to-require-property.js b/packages/backend/test/assertions/to-require-property.js new file mode 100644 index 0000000..0a6256b --- /dev/null +++ b/packages/backend/test/assertions/to-require-property.js @@ -0,0 +1,31 @@ +import { ValidationError } from 'objection'; + +export const toRequireProperty = async (model, requiredProperty) => { + try { + await model.query().insert({}); + } catch (error) { + if ( + error instanceof ValidationError && + error.message.includes( + `${requiredProperty}: must have required property '${requiredProperty}'` + ) + ) { + return { + pass: true, + message: () => + `Expected ${requiredProperty} to be required, and it was.`, + }; + } else { + return { + pass: false, + message: () => + `Expected ${requiredProperty} to be required, but it was not found in the error message.`, + }; + } + } + return { + pass: false, + message: () => + `Expected ${requiredProperty} to be required, but no ValidationError was thrown.`, + }; +}; diff --git a/packages/backend/test/factories/access-token.js b/packages/backend/test/factories/access-token.js new file mode 100644 index 0000000..da1871b --- /dev/null +++ b/packages/backend/test/factories/access-token.js @@ -0,0 +1,13 @@ +import crypto from 'crypto'; +import AccessToken from '../../src/models/access-token.js'; +import { createUser } from './user.js'; + +export const createAccessToken = async (params = {}) => { + params.userId = params.userId || (await createUser()).id; + params.token = params.token || (await crypto.randomBytes(48).toString('hex')); + params.expiresIn = params.expiresIn || 14 * 24 * 60 * 60; // 14 days in seconds + + const accessToken = await AccessToken.query().insertAndFetch(params); + + return accessToken; +}; diff --git a/packages/backend/test/factories/app-config.js b/packages/backend/test/factories/app-config.js new file mode 100644 index 0000000..d71ee99 --- /dev/null +++ b/packages/backend/test/factories/app-config.js @@ -0,0 +1,10 @@ +import AppConfig from '../../src/models/app-config.js'; +import { faker } from '@faker-js/faker'; + +export const createAppConfig = async (params = {}) => { + params.key = params?.key || faker.lorem.word(); + + const appConfig = await AppConfig.query().insertAndFetch(params); + + return appConfig; +}; diff --git a/packages/backend/test/factories/app.js b/packages/backend/test/factories/app.js new file mode 100644 index 0000000..2869c43 --- /dev/null +++ b/packages/backend/test/factories/app.js @@ -0,0 +1,72 @@ +import { faker } from '@faker-js/faker'; + +export const createArgument = (params = {}) => { + const labelAndKey = faker.lorem.word(); + + const argument = { + label: labelAndKey, + key: labelAndKey, + required: false, + variables: true, + ...params, + }; + + return argument; +}; + +export const createStringArgument = (params = {}) => { + const stringArgument = createArgument({ + ...params, + type: 'string', + }); + + return stringArgument; +}; + +export const createDropdownArgument = (params = {}) => { + const dropdownArgument = createArgument({ + options: [ + { + label: 'Yes', + value: true, + }, + { + label: 'No', + value: false, + }, + ], + ...params, + type: 'dropdown', + }); + + return dropdownArgument; +}; + +export const createDynamicArgument = (params = {}) => { + const dynamicArgument = createArgument({ + value: [ + { + key: '', + value: '', + }, + ], + fields: [ + { + label: 'Key', + key: 'key', + required: true, + variables: true, + }, + { + label: 'Value', + key: 'value', + required: true, + variables: true, + } + ], + ...params, + type: 'dynamic', + }); + + return dynamicArgument; +}; diff --git a/packages/backend/test/factories/config.js b/packages/backend/test/factories/config.js new file mode 100644 index 0000000..e786a80 --- /dev/null +++ b/packages/backend/test/factories/config.js @@ -0,0 +1,19 @@ +import Config from '../../src/models/config'; + +export const getConfig = async () => { + return await Config.get(); +}; + +export const createConfig = async (params = {}) => { + return await Config.query().insertAndFetch(params); +}; + +export const updateConfig = async (params = {}) => { + return await Config.update(params); +}; + +export const markInstallationCompleted = async () => { + return await updateConfig({ + installationCompleted: true, + }); +}; diff --git a/packages/backend/test/factories/connection.js b/packages/backend/test/factories/connection.js new file mode 100644 index 0000000..9692a3a --- /dev/null +++ b/packages/backend/test/factories/connection.js @@ -0,0 +1,23 @@ +import appConfig from '../../src/config/app'; +import { AES } from 'crypto-js'; +import Connection from '../../src/models/connection'; + +export const createConnection = async (params = {}) => { + params.key = params?.key || 'deepl'; + + const formattedData = params.formattedData || { + screenName: 'Test - DeepL Connection', + authenticationKey: 'test key', + }; + + delete params.formattedData; + + params.data = AES.encrypt( + JSON.stringify(formattedData), + appConfig.encryptionKey + ).toString(); + + const connection = await Connection.query().insertAndFetch(params); + + return connection; +}; diff --git a/packages/backend/test/factories/execution-step.js b/packages/backend/test/factories/execution-step.js new file mode 100644 index 0000000..842e3a9 --- /dev/null +++ b/packages/backend/test/factories/execution-step.js @@ -0,0 +1,15 @@ +import ExecutionStep from '../../src/models/execution-step'; +import { createExecution } from './execution'; +import { createStep } from './step'; + +export const createExecutionStep = async (params = {}) => { + params.executionId = params?.executionId || (await createExecution()).id; + params.stepId = params?.stepId || (await createStep()).id; + params.status = params?.status || 'success'; + params.dataIn = params?.dataIn || { dataIn: 'dataIn' }; + params.dataOut = params?.dataOut || { dataOut: 'dataOut' }; + + const executionStep = await ExecutionStep.query().insertAndFetch(params); + + return executionStep; +}; diff --git a/packages/backend/test/factories/execution.js b/packages/backend/test/factories/execution.js new file mode 100644 index 0000000..22ad2b7 --- /dev/null +++ b/packages/backend/test/factories/execution.js @@ -0,0 +1,11 @@ +import Execution from '../../src/models/execution'; +import { createFlow } from './flow'; + +export const createExecution = async (params = {}) => { + params.flowId = params?.flowId || (await createFlow()).id; + params.testRun = params?.testRun || false; + + const execution = await Execution.query().insertAndFetch(params); + + return execution; +}; diff --git a/packages/backend/test/factories/flow.js b/packages/backend/test/factories/flow.js new file mode 100644 index 0000000..c23d4e0 --- /dev/null +++ b/packages/backend/test/factories/flow.js @@ -0,0 +1,13 @@ +import Flow from '../../src/models/flow'; +import { createUser } from './user'; + +export const createFlow = async (params = {}) => { + params.userId = params?.userId || (await createUser()).id; + params.name = params?.name || 'Name your flow!'; + params.createdAt = params?.createdAt || new Date().toISOString(); + params.updatedAt = params?.updatedAt || new Date().toISOString(); + + const flow = await Flow.query().insertAndFetch(params); + + return flow; +}; diff --git a/packages/backend/test/factories/folder.js b/packages/backend/test/factories/folder.js new file mode 100644 index 0000000..62ca48b --- /dev/null +++ b/packages/backend/test/factories/folder.js @@ -0,0 +1,12 @@ +import Folder from '../../src/models/folder.js'; +import { faker } from '@faker-js/faker'; +import { createUser } from './user'; + +export const createFolder = async (params = {}) => { + params.userId = params?.userId || (await createUser()).id; + params.name = params?.name || faker.lorem.word(); + + const folder = await Folder.query().insertAndFetch(params); + + return folder; +}; diff --git a/packages/backend/test/factories/identity.js b/packages/backend/test/factories/identity.js new file mode 100644 index 0000000..ad5e46d --- /dev/null +++ b/packages/backend/test/factories/identity.js @@ -0,0 +1,15 @@ +import { faker } from '@faker-js/faker'; +import Identity from '../../src/models/identity.ee.js'; +import { createUser } from './user.js'; +import { createSamlAuthProvider } from './saml-auth-provider.ee.js'; + +export const createIdentity = async (params = {}) => { + params.userId = params.userId || (await createUser()).id; + params.remoteId = params.remoteId || faker.string.uuid(); + params.providerId = params.providerId || (await createSamlAuthProvider()).id; + params.providerType = 'saml'; + + const identity = await Identity.query().insertAndFetch(params); + + return identity; +}; diff --git a/packages/backend/test/factories/oauth-client.js b/packages/backend/test/factories/oauth-client.js new file mode 100644 index 0000000..0b0f6b9 --- /dev/null +++ b/packages/backend/test/factories/oauth-client.js @@ -0,0 +1,21 @@ +import { faker } from '@faker-js/faker'; +import OAuthClient from '../../src/models/oauth-client'; + +const formattedAuthDefaults = { + oAuthRedirectUrl: faker.internet.url(), + instanceUrl: faker.internet.url(), + clientId: faker.string.uuid(), + clientSecret: faker.string.uuid(), +}; + +export const createOAuthClient = async (params = {}) => { + params.name = params?.name || faker.person.fullName(); + params.appKey = params?.appKey || 'deepl'; + params.active = params?.active ?? true; + params.formattedAuthDefaults = + params?.formattedAuthDefaults || formattedAuthDefaults; + + const oauthClient = await OAuthClient.query().insertAndFetch(params); + + return oauthClient; +}; diff --git a/packages/backend/test/factories/permission.js b/packages/backend/test/factories/permission.js new file mode 100644 index 0000000..d543497 --- /dev/null +++ b/packages/backend/test/factories/permission.js @@ -0,0 +1,13 @@ +import Permission from '../../src/models/permission'; +import { createRole } from './role'; + +export const createPermission = async (params = {}) => { + params.roleId = params?.roleId || (await createRole()).id; + params.action = params?.action || 'read'; + params.subject = params?.subject || 'User'; + params.conditions = params?.conditions || ['isCreator']; + + const permission = await Permission.query().insertAndFetch(params); + + return permission; +}; diff --git a/packages/backend/test/factories/role-mapping.js b/packages/backend/test/factories/role-mapping.js new file mode 100644 index 0000000..19352dd --- /dev/null +++ b/packages/backend/test/factories/role-mapping.js @@ -0,0 +1,15 @@ +import { faker } from '@faker-js/faker'; +import { createRole } from './role.js'; +import RoleMapping from '../../src/models/role-mapping.ee.js'; +import { createSamlAuthProvider } from './saml-auth-provider.ee.js'; + +export const createRoleMapping = async (params = {}) => { + params.roleId = params.roleId || (await createRole()).id; + params.samlAuthProviderId = + params.samlAuthProviderId || (await createSamlAuthProvider()).id; + params.remoteRoleName = params.remoteRoleName || faker.person.jobType(); + + const roleMapping = await RoleMapping.query().insertAndFetch(params); + + return roleMapping; +}; diff --git a/packages/backend/test/factories/role.js b/packages/backend/test/factories/role.js new file mode 100644 index 0000000..34023c7 --- /dev/null +++ b/packages/backend/test/factories/role.js @@ -0,0 +1,18 @@ +import { faker } from '@faker-js/faker'; +import Role from '../../src/models/role.js'; + +export const createRole = async (params = {}) => { + const name = faker.lorem.word(); + + params.name = params?.name || name; + + const existingRole = await Role.query().findOne({ name }).first(); + + if (existingRole) { + return await createRole(); + } + + const role = await Role.query().insertAndFetch(params); + + return role; +}; diff --git a/packages/backend/test/factories/saml-auth-provider.ee.js b/packages/backend/test/factories/saml-auth-provider.ee.js new file mode 100644 index 0000000..f205eda --- /dev/null +++ b/packages/backend/test/factories/saml-auth-provider.ee.js @@ -0,0 +1,33 @@ +import { createRole } from './role'; +import SamlAuthProvider from '../../src/models/saml-auth-provider.ee.js'; + +export const createSamlAuthProvider = async (params = {}) => { + params.name = params?.name || 'Keycloak SAML'; + params.certificate = params?.certificate || 'certificate'; + params.signatureAlgorithm = params?.signatureAlgorithm || 'sha512'; + + params.entryPoint = + params?.entryPoint || + 'https://example.com/auth/realms/automatisch/protocol/saml'; + + params.issuer = params?.issuer || 'automatisch-client'; + + params.firstnameAttributeName = + params?.firstnameAttributeName || 'urn:oid:2.1.1.42'; + + params.surnameAttributeName = + params?.surnameAttributeName || 'urn:oid:2.1.1.4'; + + params.emailAttributeName = + params?.emailAttributeName || 'urn:oid:1.1.2342.19200300.100.1.1'; + + params.roleAttributeName = params?.roleAttributeName || 'Role'; + params.defaultRoleId = params?.defaultRoleId || (await createRole()).id; + params.active = params?.active || true; + + const samlAuthProvider = await SamlAuthProvider.query().insertAndFetch( + params + ); + + return samlAuthProvider; +}; diff --git a/packages/backend/test/factories/step.js b/packages/backend/test/factories/step.js new file mode 100644 index 0000000..a52c9d4 --- /dev/null +++ b/packages/backend/test/factories/step.js @@ -0,0 +1,30 @@ +import Step from '../../src/models/step'; +import { createFlow } from './flow'; + +export const createStep = async (params = {}) => { + params.flowId = params?.flowId || (await createFlow()).id; + params.type = params?.type || 'action'; + + const lastStep = await Step.query() + .where('flow_id', params.flowId) + .andWhere('deleted_at', null) + .orderBy('position', 'desc') + .limit(1) + .first(); + + params.position = + params?.position || (lastStep?.position ? lastStep.position + 1 : 1); + + params.status = params?.status || 'completed'; + + if (params.appKey !== null) { + params.appKey = + params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook'); + } + + params.parameters = params?.parameters || {}; + + const step = await Step.query().insertAndFetch(params); + + return step; +}; diff --git a/packages/backend/test/factories/subscription.js b/packages/backend/test/factories/subscription.js new file mode 100644 index 0000000..f63bcc9 --- /dev/null +++ b/packages/backend/test/factories/subscription.js @@ -0,0 +1,21 @@ +import { DateTime } from 'luxon'; +import { createUser } from './user'; +import Subscription from '../../src/models/subscription.ee.js'; + +export const createSubscription = async (params = {}) => { + params.userId = params?.userId || (await createUser()).id; + params.paddleSubscriptionId = + params?.paddleSubscriptionId || 'paddleSubscriptionId'; + + params.paddlePlanId = params?.paddlePlanId || '47384'; + params.updateUrl = params?.updateUrl || 'https://example.com/update-url'; + params.cancelUrl = params?.cancelUrl || 'https://example.com/cancel-url'; + params.status = params?.status || 'active'; + params.nextBillAmount = params?.nextBillAmount || '20'; + params.nextBillDate = + params?.nextBillDate || DateTime.now().plus({ days: 30 }).toISODate(); + + const subscription = await Subscription.query().insertAndFetch(params); + + return subscription; +}; diff --git a/packages/backend/test/factories/usage-data.js b/packages/backend/test/factories/usage-data.js new file mode 100644 index 0000000..c6d0761 --- /dev/null +++ b/packages/backend/test/factories/usage-data.js @@ -0,0 +1,15 @@ +import { DateTime } from 'luxon'; +import { createUser } from './user'; +import UsageData from '../../src/models/usage-data.ee.js'; + +export const createUsageData = async (params = {}) => { + params.userId = params?.userId || (await createUser()).id; + params.nextResetAt = + params?.nextResetAt || DateTime.now().plus({ days: 30 }).toISODate(); + + params.consumedTaskCount = params?.consumedTaskCount || 0; + + const usageData = await UsageData.query().insertAndFetch(params); + + return usageData; +}; diff --git a/packages/backend/test/factories/user.js b/packages/backend/test/factories/user.js new file mode 100644 index 0000000..fe4719f --- /dev/null +++ b/packages/backend/test/factories/user.js @@ -0,0 +1,14 @@ +import { createRole } from './role'; +import { faker } from '@faker-js/faker'; +import User from '../../src/models/user'; + +export const createUser = async (params = {}) => { + params.roleId = params?.roleId || (await createRole()).id; + params.fullName = params?.fullName || faker.person.fullName(); + params.email = params?.email || faker.internet.email(); + params.password = params?.password || faker.internet.password(); + + const user = await User.query().insertAndFetch(params); + + return user; +}; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js new file mode 100644 index 0000000..8fb199d --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js @@ -0,0 +1,18 @@ +const createAppConfigMock = (appConfig) => { + return { + data: { + key: appConfig.key, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, + disabled: appConfig.disabled, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'AppConfig', + }, + }; +}; + +export default createAppConfigMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js new file mode 100644 index 0000000..10e4e9b --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js @@ -0,0 +1,17 @@ +const createOAuthClientMock = (oauthClient) => { + return { + data: { + name: oauthClient.name, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default createOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js new file mode 100644 index 0000000..1431b96 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js @@ -0,0 +1,18 @@ +const getOAuthClientMock = (oauthClient) => { + return { + data: { + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js new file mode 100644 index 0000000..c0bd5d5 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js @@ -0,0 +1,18 @@ +const getAdminOAuthClientsMock = (oauthClients) => { + return { + data: oauthClients.map((oauthClient) => ({ + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + })), + meta: { + count: oauthClients.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getAdminOAuthClientsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js new file mode 100644 index 0000000..bdb5294 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js @@ -0,0 +1,18 @@ +const updateOAuthClientMock = (oauthClient) => { + return { + data: { + id: oauthClient.id, + name: oauthClient.name, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default updateOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/config/update.js b/packages/backend/test/mocks/rest/api/v1/admin/config/update.js new file mode 100644 index 0000000..19d2efb --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/config/update.js @@ -0,0 +1,26 @@ +const updateConfigMock = ( + logoConfig, + primaryDarkConfig, + primaryLightConfig, + primaryMainConfig, + titleConfig +) => { + return { + data: { + logoSvgData: logoConfig, + 'palette.primary.dark': primaryDarkConfig, + 'palette.primary.light': primaryLightConfig, + 'palette.primary.main': primaryMainConfig, + title: titleConfig, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default updateConfigMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/permissions/get-permissions-catalog.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/permissions/get-permissions-catalog.ee.js new file mode 100644 index 0000000..627bfa3 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/permissions/get-permissions-catalog.ee.js @@ -0,0 +1,64 @@ +const getPermissionsCatalogMock = async () => { + const data = { + actions: [ + { + key: 'create', + label: 'Create', + subjects: ['Connection', 'Flow'], + }, + { + key: 'read', + label: 'Read', + subjects: ['Connection', 'Execution', 'Flow'], + }, + { + key: 'update', + label: 'Update', + subjects: ['Connection', 'Flow'], + }, + { + key: 'delete', + label: 'Delete', + subjects: ['Connection', 'Flow'], + }, + { + key: 'publish', + label: 'Publish', + subjects: ['Flow'], + }, + ], + conditions: [ + { + key: 'isCreator', + label: 'Is creator', + }, + ], + subjects: [ + { + key: 'Connection', + label: 'Connection', + }, + { + key: 'Flow', + label: 'Flow', + }, + { + key: 'Execution', + label: 'Execution', + }, + ], + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getPermissionsCatalogMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/roles/create-role.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/roles/create-role.ee.js new file mode 100644 index 0000000..caa5b4c --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/roles/create-role.ee.js @@ -0,0 +1,32 @@ +const createRoleMock = async (role, permissions = []) => { + const data = { + id: role.id, + name: role.name, + isAdmin: role.isAdmin, + description: role.description, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + permissions: permissions.map((permission) => ({ + id: permission.id, + action: permission.action, + conditions: permission.conditions, + roleId: permission.roleId, + subject: permission.subject, + createdAt: permission.createdAt.getTime(), + updatedAt: permission.updatedAt.getTime(), + })), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Role', + }, + }; +}; + +export default createRoleMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/roles/get-role.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/roles/get-role.ee.js new file mode 100644 index 0000000..df46557 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/roles/get-role.ee.js @@ -0,0 +1,32 @@ +const getRoleMock = async (role, permissions) => { + const data = { + id: role.id, + name: role.name, + isAdmin: role.isAdmin, + description: role.description, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + permissions: permissions.map((permission) => ({ + id: permission.id, + action: permission.action, + conditions: permission.conditions, + roleId: permission.roleId, + subject: permission.subject, + createdAt: permission.createdAt.getTime(), + updatedAt: permission.updatedAt.getTime(), + })), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Role', + }, + }; +}; + +export default getRoleMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/roles/get-roles.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/roles/get-roles.ee.js new file mode 100644 index 0000000..b37e2f7 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/roles/get-roles.ee.js @@ -0,0 +1,25 @@ +const getRolesMock = async (roles) => { + const data = roles.map((role) => { + return { + id: role.id, + name: role.name, + isAdmin: role.isAdmin, + description: role.description, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Role', + }, + }; +}; + +export default getRolesMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/roles/update-role.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/roles/update-role.ee.js new file mode 100644 index 0000000..164ea4b --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/roles/update-role.ee.js @@ -0,0 +1,32 @@ +const updateRoleMock = (role, permissions = []) => { + const data = { + id: role.id, + name: role.name, + isAdmin: role.isAdmin, + description: role.description, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + permissions: permissions.map((permission) => ({ + id: permission.id, + action: permission.action, + conditions: permission.conditions, + roleId: permission.roleId, + subject: permission.subject, + createdAt: permission.createdAt.getTime(), + updatedAt: permission.updatedAt.getTime(), + })), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Role', + }, + }; +}; + +export default updateRoleMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js new file mode 100644 index 0000000..2a2a733 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/create-saml-auth-provider.ee.js @@ -0,0 +1,29 @@ +const createSamlAuthProviderMock = async (samlAuthProvider) => { + const data = { + active: samlAuthProvider.active, + certificate: samlAuthProvider.certificate, + defaultRoleId: samlAuthProvider.defaultRoleId, + emailAttributeName: samlAuthProvider.emailAttributeName, + entryPoint: samlAuthProvider.entryPoint, + firstnameAttributeName: samlAuthProvider.firstnameAttributeName, + id: samlAuthProvider.id, + issuer: samlAuthProvider.issuer, + name: samlAuthProvider.name, + roleAttributeName: samlAuthProvider.roleAttributeName, + signatureAlgorithm: samlAuthProvider.signatureAlgorithm, + surnameAttributeName: samlAuthProvider.surnameAttributeName, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'SamlAuthProvider', + }, + }; +}; + +export default createSamlAuthProviderMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js new file mode 100644 index 0000000..dcd8304 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-role-mappings.ee.js @@ -0,0 +1,23 @@ +const getRoleMappingsMock = async (roleMappings) => { + const data = roleMappings.map((roleMapping) => { + return { + id: roleMapping.id, + samlAuthProviderId: roleMapping.samlAuthProviderId, + roleId: roleMapping.roleId, + remoteRoleName: roleMapping.remoteRoleName, + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'RoleMapping', + }, + }; +}; + +export default getRoleMappingsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js new file mode 100644 index 0000000..7a2d4c7 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-provider.ee.js @@ -0,0 +1,29 @@ +const getSamlAuthProviderMock = async (samlAuthProvider) => { + const data = { + active: samlAuthProvider.active, + certificate: samlAuthProvider.certificate, + defaultRoleId: samlAuthProvider.defaultRoleId, + emailAttributeName: samlAuthProvider.emailAttributeName, + entryPoint: samlAuthProvider.entryPoint, + firstnameAttributeName: samlAuthProvider.firstnameAttributeName, + id: samlAuthProvider.id, + issuer: samlAuthProvider.issuer, + name: samlAuthProvider.name, + roleAttributeName: samlAuthProvider.roleAttributeName, + signatureAlgorithm: samlAuthProvider.signatureAlgorithm, + surnameAttributeName: samlAuthProvider.surnameAttributeName, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'SamlAuthProvider', + }, + }; +}; + +export default getSamlAuthProviderMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js new file mode 100644 index 0000000..30d5bfc --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/get-saml-auth-providers.ee.js @@ -0,0 +1,31 @@ +const getSamlAuthProvidersMock = async (samlAuthProviders) => { + const data = samlAuthProviders.map((samlAuthProvider) => { + return { + active: samlAuthProvider.active, + certificate: samlAuthProvider.certificate, + defaultRoleId: samlAuthProvider.defaultRoleId, + emailAttributeName: samlAuthProvider.emailAttributeName, + entryPoint: samlAuthProvider.entryPoint, + firstnameAttributeName: samlAuthProvider.firstnameAttributeName, + id: samlAuthProvider.id, + issuer: samlAuthProvider.issuer, + name: samlAuthProvider.name, + roleAttributeName: samlAuthProvider.roleAttributeName, + signatureAlgorithm: samlAuthProvider.signatureAlgorithm, + surnameAttributeName: samlAuthProvider.surnameAttributeName, + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'SamlAuthProvider', + }, + }; +}; + +export default getSamlAuthProvidersMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js new file mode 100644 index 0000000..e921150 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js @@ -0,0 +1,23 @@ +const createRoleMappingsMock = async (roleMappings) => { + const data = roleMappings.map((roleMapping) => { + return { + id: roleMapping.id, + samlAuthProviderId: roleMapping.samlAuthProviderId, + roleId: roleMapping.roleId, + remoteRoleName: roleMapping.remoteRoleName, + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'RoleMapping', + }, + }; +}; + +export default createRoleMappingsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js b/packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js new file mode 100644 index 0000000..30aaef2 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js @@ -0,0 +1,30 @@ +import appConfig from '../../../../../../../src/config/app.js'; + +const createUserMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + acceptInvitationUrl: user.acceptInvitationUrl, + }; + + if (appConfig.isCloud && user.trialExpiryDate) { + userData.trialExpiryDate = user.trialExpiryDate.toISOString(); + } + + return { + data: userData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default createUserMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js b/packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js new file mode 100644 index 0000000..fcf40ec --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js @@ -0,0 +1,30 @@ +const getUserMock = (currentUser, role) => { + return { + data: { + createdAt: currentUser.createdAt.getTime(), + email: currentUser.email, + fullName: currentUser.fullName, + id: currentUser.id, + role: { + createdAt: role.createdAt.getTime(), + description: null, + id: role.id, + isAdmin: role.isAdmin, + name: role.name, + updatedAt: role.updatedAt.getTime(), + }, + status: currentUser.status, + trialExpiryDate: currentUser.trialExpiryDate.toISOString(), + updatedAt: currentUser.updatedAt.getTime(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default getUserMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js b/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js new file mode 100644 index 0000000..7daff40 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js @@ -0,0 +1,38 @@ +const getUsersMock = async (users, roles) => { + const data = users.map((user) => { + const role = roles.find((r) => r.id === user.roleId); + + return { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + role: role + ? { + createdAt: role.createdAt.getTime(), + description: role.description, + id: role.id, + isAdmin: role.isAdmin, + name: role.name, + updatedAt: role.updatedAt.getTime(), + } + : null, + status: user.status, + trialExpiryDate: user.trialExpiryDate.toISOString(), + updatedAt: user.updatedAt.getTime(), + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: 1, + isArray: true, + totalPages: 1, + type: 'User', + }, + }; +}; + +export default getUsersMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/users/update-user.js b/packages/backend/test/mocks/rest/api/v1/admin/users/update-user.js new file mode 100644 index 0000000..f09b2a1 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/update-user.js @@ -0,0 +1,28 @@ +const updateUserMock = (user, role) => { + return { + data: { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + role: { + id: role.id, + name: role.name, + isAdmin: role.isAdmin, + createdAt: role.createdAt.getTime(), + updatedAt: role.updatedAt.getTime(), + }, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default updateUserMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js b/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js new file mode 100644 index 0000000..ccbeba2 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js @@ -0,0 +1,24 @@ +const createConnection = (connection) => { + const connectionData = { + id: connection.id, + key: connection.key, + oauthClientId: connection.oauthClientId, + formattedData: connection.formattedData, + verified: connection.verified || false, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; + + return { + data: connectionData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Connection', + }, + }; +}; + +export default createConnection; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-action-substeps.js b/packages/backend/test/mocks/rest/api/v1/apps/get-action-substeps.js new file mode 100644 index 0000000..29c5f5b --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-action-substeps.js @@ -0,0 +1,14 @@ +const getActionSubstepsMock = (substeps) => { + return { + data: substeps, + meta: { + count: substeps.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getActionSubstepsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-actions.js b/packages/backend/test/mocks/rest/api/v1/apps/get-actions.js new file mode 100644 index 0000000..913d134 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-actions.js @@ -0,0 +1,22 @@ +const getActionsMock = (actions) => { + const actionsData = actions.map((trigger) => { + return { + name: trigger.name, + key: trigger.key, + description: trigger.description, + }; + }); + + return { + data: actionsData, + meta: { + count: actions.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getActionsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-app.js b/packages/backend/test/mocks/rest/api/v1/apps/get-app.js new file mode 100644 index 0000000..2506191 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-app.js @@ -0,0 +1,22 @@ +const getAppMock = (app) => { + return { + data: { + authDocUrl: app.authDocUrl, + iconUrl: app.iconUrl, + key: app.key, + name: app.name, + primaryColor: app.primaryColor, + supportsConnections: app.supportsConnections, + supportsOauthClients: app.auth.generateAuthUrl ? true : false, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getAppMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js b/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js new file mode 100644 index 0000000..e1892d4 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js @@ -0,0 +1,24 @@ +const getAppsMock = (apps) => { + const appsData = apps.map((app) => ({ + authDocUrl: app.authDocUrl, + iconUrl: app.iconUrl, + key: app.key, + name: app.name, + primaryColor: app.primaryColor, + supportsConnections: app.supportsConnections, + supportsOauthClients: app?.auth?.generateAuthUrl ? true : false, + })); + + return { + data: appsData, + meta: { + count: appsData.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getAppsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js b/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js new file mode 100644 index 0000000..d42b972 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js @@ -0,0 +1,20 @@ +const getAuthMock = (auth) => { + return { + data: { + fields: auth.fields, + authenticationSteps: auth.authenticationSteps, + reconnectionSteps: auth.reconnectionSteps, + sharedReconnectionSteps: auth.sharedReconnectionSteps, + sharedAuthenticationSteps: auth.sharedAuthenticationSteps, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getAuthMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-config.js b/packages/backend/test/mocks/rest/api/v1/apps/get-config.js new file mode 100644 index 0000000..97827f5 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-config.js @@ -0,0 +1,20 @@ +const getAppConfigMock = (appConfig) => { + return { + data: { + key: appConfig.key, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, + disabled: appConfig.disabled, + createdAt: appConfig.createdAt.getTime(), + updatedAt: appConfig.updatedAt.getTime(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'AppConfig', + }, + }; +}; + +export default getAppConfigMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js b/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js new file mode 100644 index 0000000..d7b9f0e --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js @@ -0,0 +1,24 @@ +const getConnectionsMock = (connections) => { + return { + data: connections.map((connection) => ({ + id: connection.id, + key: connection.key, + verified: connection.verified, + oauthClientId: connection.oauthClientId, + formattedData: { + screenName: connection.formattedData.screenName, + }, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + })), + meta: { + count: connections.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Connection', + }, + }; +}; + +export default getConnectionsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js new file mode 100644 index 0000000..1431b96 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js @@ -0,0 +1,18 @@ +const getOAuthClientMock = (oauthClient) => { + return { + data: { + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js new file mode 100644 index 0000000..549544b --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js @@ -0,0 +1,18 @@ +const getOAuthClientsMock = (oauthClients) => { + return { + data: oauthClients.map((oauthClient) => ({ + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + })), + meta: { + count: oauthClients.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getOAuthClientsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-trigger-substeps.js b/packages/backend/test/mocks/rest/api/v1/apps/get-trigger-substeps.js new file mode 100644 index 0000000..baad19d --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-trigger-substeps.js @@ -0,0 +1,14 @@ +const getTriggerSubstepsMock = (substeps) => { + return { + data: substeps, + meta: { + count: substeps.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getTriggerSubstepsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-triggers.js b/packages/backend/test/mocks/rest/api/v1/apps/get-triggers.js new file mode 100644 index 0000000..cca0132 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-triggers.js @@ -0,0 +1,25 @@ +const getTriggersMock = (triggers) => { + const triggersData = triggers.map((trigger) => { + return { + description: trigger.description, + key: trigger.key, + name: trigger.name, + pollInterval: trigger.pollInterval, + showWebhookUrl: trigger.showWebhookUrl, + type: trigger.type, + }; + }); + + return { + data: triggersData, + meta: { + count: triggers.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getTriggersMock; diff --git a/packages/backend/test/mocks/rest/api/v1/automatisch/config.js b/packages/backend/test/mocks/rest/api/v1/automatisch/config.js new file mode 100644 index 0000000..62ae351 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/automatisch/config.js @@ -0,0 +1,29 @@ +const configMock = (config) => { + return { + data: { + id: config.id, + updatedAt: config.updatedAt.getTime(), + createdAt: config.createdAt.getTime(), + disableFavicon: config.disableFavicon, + disableNotificationsPage: config.disableNotificationsPage, + additionalDrawerLink: config.additionalDrawerLink, + additionalDrawerLinkIcon: config.additionalDrawerLinkIcon, + additionalDrawerLinkText: config.additionalDrawerLinkText, + logoSvgData: config.logoSvgData, + palettePrimaryDark: config.palettePrimaryDark, + palettePrimaryMain: config.palettePrimaryMain, + palettePrimaryLight: config.palettePrimaryLight, + installationCompleted: config.installationCompleted || false, + title: config.title, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Config', + }, + }; +}; + +export default configMock; diff --git a/packages/backend/test/mocks/rest/api/v1/automatisch/info.js b/packages/backend/test/mocks/rest/api/v1/automatisch/info.js new file mode 100644 index 0000000..e1da688 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/automatisch/info.js @@ -0,0 +1,20 @@ +const infoMock = () => { + return { + data: { + docsUrl: 'https://automatisch.io/docs', + installationCompleted: true, + isCloud: false, + isEnterprise: true, + isMation: false, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default infoMock; diff --git a/packages/backend/test/mocks/rest/api/v1/automatisch/license.js b/packages/backend/test/mocks/rest/api/v1/automatisch/license.js new file mode 100644 index 0000000..1a02ebb --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/automatisch/license.js @@ -0,0 +1,19 @@ +const licenseMock = () => { + return { + data: { + expireAt: '2025-12-31T23:59:59Z', + id: '123', + name: 'license-name', + verified: true, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default licenseMock; diff --git a/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js b/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js new file mode 100644 index 0000000..f618a64 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js @@ -0,0 +1,26 @@ +const resetConnectionMock = (connection) => { + const data = { + id: connection.id, + key: connection.key, + verified: connection.verified, + oauthClientId: connection.oauthClientId, + formattedData: { + screenName: connection.formattedData.screenName, + }, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Connection', + }, + }; +}; + +export default resetConnectionMock; diff --git a/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js b/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js new file mode 100644 index 0000000..306f772 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js @@ -0,0 +1,26 @@ +const updateConnectionMock = (connection) => { + const data = { + id: connection.id, + key: connection.key, + verified: connection.verified, + oauthClientId: connection.oauthClientId, + formattedData: { + screenName: connection.formattedData.screenName, + }, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Connection', + }, + }; +}; + +export default updateConnectionMock; diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js new file mode 100644 index 0000000..f694f70 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js @@ -0,0 +1,40 @@ +const getExecutionStepsMock = async (executionSteps, steps) => { + const data = executionSteps.map((executionStep) => { + const step = steps.find((step) => step.id === executionStep.stepId); + + return { + id: executionStep.id, + dataIn: executionStep.dataIn, + dataOut: executionStep.dataOut, + errorDetails: executionStep.errorDetails, + status: executionStep.status, + createdAt: executionStep.createdAt.getTime(), + updatedAt: executionStep.updatedAt.getTime(), + step: { + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + }, + }; + }); + + return { + data: data, + meta: { + count: executionSteps.length, + currentPage: 1, + isArray: true, + totalPages: 1, + type: 'ExecutionStep', + }, + }; +}; + +export default getExecutionStepsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js new file mode 100644 index 0000000..61feddd --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js @@ -0,0 +1,41 @@ +const getExecutionMock = async (execution, flow, steps) => { + const data = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + flow: { + id: flow.id, + name: flow.name, + active: flow.active, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + steps: steps.map((step) => ({ + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + })), + }, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Execution', + }, + }; +}; + +export default getExecutionMock; diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js new file mode 100644 index 0000000..b194bee --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js @@ -0,0 +1,42 @@ +const getExecutionsMock = async (executions, flow, steps) => { + const data = executions.map((execution) => ({ + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + status: 'success', + flow: { + id: flow.id, + name: flow.name, + active: flow.active, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + steps: steps.map((step) => ({ + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + })), + }, + })); + + return { + data: data, + meta: { + count: executions.length, + currentPage: 1, + isArray: true, + totalPages: 1, + type: 'Execution', + }, + }; +}; + +export default getExecutionsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js new file mode 100644 index 0000000..cb7606c --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/create-flow.js @@ -0,0 +1,23 @@ +const createFlowMock = async (flow) => { + const data = { + id: flow.id, + active: flow.active, + name: flow.name, + status: flow.status, + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default createFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/create-step.js b/packages/backend/test/mocks/rest/api/v1/flows/create-step.js new file mode 100644 index 0000000..da23f51 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/create-step.js @@ -0,0 +1,26 @@ +const createStepMock = async (step) => { + const data = { + id: step.id, + type: step.type || 'action', + key: step.key || null, + appKey: step.appKey || null, + iconUrl: step.iconUrl || null, + webhookUrl: step.webhookUrl || null, + status: step.status || 'incomplete', + position: step.position, + parameters: step.parameters || {}, + }; + + return { + data, + meta: { + type: 'Step', + count: 1, + isArray: false, + currentPage: null, + totalPages: null, + }, + }; +}; + +export default createStepMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js new file mode 100644 index 0000000..3642677 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js @@ -0,0 +1,38 @@ +const duplicateFlowMock = async (flow, steps = []) => { + const data = { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + id: step.id, + key: step.key, + name: step.name, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + webhookUrl: step.webhookUrl, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default duplicateFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js new file mode 100644 index 0000000..c7a1ef6 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -0,0 +1,41 @@ +import { expect } from 'vitest'; + +const exportFlowMock = async (flow, steps = []) => { + const data = { + id: expect.any(String), + name: flow.name, + }; + + if (steps.length) { + data.steps = steps.map((step) => { + const computedStep = { + id: expect.any(String), + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: expect.any(Object), + position: step.position, + }; + + if (step.type === 'trigger') { + computedStep.webhookPath = expect.stringContaining('/webhooks/flows/'); + } + + return computedStep; + }); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default exportFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js new file mode 100644 index 0000000..49efe83 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js @@ -0,0 +1,38 @@ +const getFlowMock = async (flow, steps = []) => { + const data = { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + id: step.id, + key: step.key, + name: step.name, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + webhookUrl: step.webhookUrl, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default getFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js new file mode 100644 index 0000000..6012a6f --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js @@ -0,0 +1,39 @@ +const getFlowsMock = async (flows, steps) => { + const data = flows.map((flow) => { + const flowSteps = steps.filter((step) => step.flowId === flow.id); + + return { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + steps: flowSteps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + id: step.id, + key: step.key, + name: step.name, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + webhookUrl: step.webhookUrl, + })), + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: 1, + isArray: true, + totalPages: 1, + type: 'Flow', + }, + }; +}; + +export default getFlowsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js new file mode 100644 index 0000000..abceb58 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js @@ -0,0 +1,35 @@ +import { expect } from 'vitest'; + +const importFlowMock = async (flow, steps = []) => { + const data = { + name: flow.name, + status: flow.active ? 'published' : 'draft', + active: flow.active, + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + key: step.key, + name: step.name, + parameters: expect.any(Object), + position: step.position, + status: 'incomplete', + type: step.type, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default importFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js new file mode 100644 index 0000000..bace9dc --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js @@ -0,0 +1,29 @@ +const updateFlowFolderMock = async (flow, folder) => { + const data = { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + folder: { + id: folder.id, + name: folder.name, + createdAt: folder.createdAt.getTime(), + updatedAt: folder.updatedAt.getTime(), + }, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default updateFlowFolderMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js new file mode 100644 index 0000000..f7c32b3 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js @@ -0,0 +1,38 @@ +const updateFlowStatusMock = async (flow, steps = []) => { + const data = { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + id: step.id, + key: step.key, + name: step.name, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + webhookUrl: step.webhookUrl, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default updateFlowStatusMock; diff --git a/packages/backend/test/mocks/rest/api/v1/folders/create-folder.js b/packages/backend/test/mocks/rest/api/v1/folders/create-folder.js new file mode 100644 index 0000000..8967a49 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/folders/create-folder.js @@ -0,0 +1,21 @@ +const createFolderMock = async (flow) => { + const data = { + id: flow.id, + name: flow.name, + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Folder', + }, + }; +}; + +export default createFolderMock; diff --git a/packages/backend/test/mocks/rest/api/v1/folders/get-folders.js b/packages/backend/test/mocks/rest/api/v1/folders/get-folders.js new file mode 100644 index 0000000..2dc7fbb --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/folders/get-folders.js @@ -0,0 +1,23 @@ +const getFoldersMock = async (folders) => { + const data = folders.map((folder) => { + return { + id: folder.id, + name: folder.name, + createdAt: folder.createdAt.getTime(), + updatedAt: folder.updatedAt.getTime(), + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Folder', + }, + }; +}; + +export default getFoldersMock; diff --git a/packages/backend/test/mocks/rest/api/v1/folders/update-folder.js b/packages/backend/test/mocks/rest/api/v1/folders/update-folder.js new file mode 100644 index 0000000..6e3ab66 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/folders/update-folder.js @@ -0,0 +1,21 @@ +const updateFolderMock = async (flow) => { + const data = { + id: flow.id, + name: flow.name, + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Folder', + }, + }; +}; + +export default updateFolderMock; diff --git a/packages/backend/test/mocks/rest/api/v1/payment/get-paddle-info.js b/packages/backend/test/mocks/rest/api/v1/payment/get-paddle-info.js new file mode 100644 index 0000000..c7b8aec --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/payment/get-paddle-info.js @@ -0,0 +1,17 @@ +const getPaddleInfoMock = async () => { + return { + data: { + sandbox: true, + vendorId: 'sampleVendorId', + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getPaddleInfoMock; diff --git a/packages/backend/test/mocks/rest/api/v1/payment/get-plans.js b/packages/backend/test/mocks/rest/api/v1/payment/get-plans.js new file mode 100644 index 0000000..6cacbf8 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/payment/get-plans.js @@ -0,0 +1,22 @@ +const getPaymentPlansMock = async () => { + return { + data: [ + { + limit: '10,000', + name: '10k - monthly', + price: '€20', + productId: '47384', + quota: 10000, + }, + ], + meta: { + count: 1, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getPaymentPlansMock; diff --git a/packages/backend/test/mocks/rest/api/v1/saml-auth-providers/get-saml-auth-providers.js b/packages/backend/test/mocks/rest/api/v1/saml-auth-providers/get-saml-auth-providers.js new file mode 100644 index 0000000..3226e12 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/saml-auth-providers/get-saml-auth-providers.js @@ -0,0 +1,23 @@ +const getSamlAuthProvidersMock = async (samlAuthProviders) => { + const data = samlAuthProviders.map((samlAuthProvider) => { + return { + id: samlAuthProvider.id, + name: samlAuthProvider.name, + loginUrl: samlAuthProvider.loginUrl, + issuer: samlAuthProvider.issuer, + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'SamlAuthProvider', + }, + }; +}; + +export default getSamlAuthProvidersMock; diff --git a/packages/backend/test/mocks/rest/api/v1/steps/create-dynamic-fields.js b/packages/backend/test/mocks/rest/api/v1/steps/create-dynamic-fields.js new file mode 100644 index 0000000..e7b1605 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/steps/create-dynamic-fields.js @@ -0,0 +1,36 @@ +const createDynamicFieldsMock = async () => { + const data = [ + { + label: 'Bot name', + key: 'botName', + type: 'string', + required: true, + value: 'Automatisch', + description: + 'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.', + variables: true, + }, + { + label: 'Bot icon', + key: 'botIcon', + type: 'string', + required: false, + description: + 'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:', + variables: true, + }, + ]; + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default createDynamicFieldsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js b/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js new file mode 100644 index 0000000..1873130 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js @@ -0,0 +1,26 @@ +const getConnectionMock = async (connection) => { + const data = { + id: connection.id, + key: connection.key, + verified: connection.verified, + oauthClientId: connection.oauthClientId, + formattedData: { + screenName: connection.formattedData.screenName, + }, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Connection', + }, + }; +}; + +export default getConnectionMock; diff --git a/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js b/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js new file mode 100644 index 0000000..4ae477d --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js @@ -0,0 +1,42 @@ +const getPreviousStepsMock = async (steps, executionSteps) => { + const data = steps.map((step) => { + const filteredExecutionSteps = executionSteps.filter( + (executionStep) => executionStep.stepId === step.id + ); + + return { + id: step.id, + type: step.type, + key: step.key, + name: step.name, + appKey: step.appKey, + iconUrl: step.iconUrl, + webhookUrl: step.webhookUrl, + status: step.status, + position: step.position, + parameters: step.parameters, + executionSteps: filteredExecutionSteps.map((executionStep) => ({ + id: executionStep.id, + dataIn: executionStep.dataIn, + dataOut: executionStep.dataOut, + errorDetails: executionStep.errorDetails, + status: executionStep.status, + createdAt: executionStep.createdAt.getTime(), + updatedAt: executionStep.updatedAt.getTime(), + })), + }; + }); + + return { + data: data, + meta: { + count: data.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Step', + }, + }; +}; + +export default getPreviousStepsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/steps/test-step.js b/packages/backend/test/mocks/rest/api/v1/steps/test-step.js new file mode 100644 index 0000000..85ef6ca --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/steps/test-step.js @@ -0,0 +1,34 @@ +const testStepMock = async (step, lastExecutionStep) => { + const data = { + id: step.id, + appKey: step.appKey, + key: step.key, + iconUrl: step.iconUrl, + lastExecutionStep: { + id: lastExecutionStep.id, + status: lastExecutionStep.status, + dataIn: lastExecutionStep.dataIn, + dataOut: lastExecutionStep.dataOut, + errorDetails: lastExecutionStep.errorDetails, + createdAt: lastExecutionStep.createdAt.getTime(), + updatedAt: lastExecutionStep.updatedAt.getTime(), + }, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Step', + }, + }; +}; + +export default testStepMock; diff --git a/packages/backend/test/mocks/rest/api/v1/steps/update-step.js b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js new file mode 100644 index 0000000..a7ad0de --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js @@ -0,0 +1,27 @@ +const updateStepMock = (step) => { + const data = { + id: step.id, + type: step.type || 'action', + key: step.key || null, + name: step.name || null, + appKey: step.appKey || null, + iconUrl: step.iconUrl || null, + webhookUrl: step.webhookUrl || null, + status: step.status || 'incomplete', + position: step.position, + parameters: step.parameters || {}, + }; + + return { + data, + meta: { + type: 'Step', + count: 1, + isArray: false, + currentPage: null, + totalPages: null, + }, + }; +}; + +export default updateStepMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/create-user.js b/packages/backend/test/mocks/rest/api/v1/users/create-user.js new file mode 100644 index 0000000..fca60d2 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/create-user.js @@ -0,0 +1,29 @@ +import appConfig from '../../../../../../src/config/app.js'; + +const createUserMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + }; + + if (appConfig.isCloud && user.trialExpiryDate) { + userData.trialExpiryDate = user.trialExpiryDate.toISOString(); + } + + return { + data: userData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default createUserMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-apps.js b/packages/backend/test/mocks/rest/api/v1/users/get-apps.js new file mode 100644 index 0000000..e67a23a --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/get-apps.js @@ -0,0 +1,55 @@ +const getAppsMock = () => { + const appsData = [ + { + authDocUrl: 'https://automatisch.io/docs/apps/deepl/connection', + connectionCount: 1, + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/deepl/assets/favicon.svg', + key: 'deepl', + name: 'DeepL', + primaryColor: '#0d2d45', + supportsConnections: true, + }, + { + authDocUrl: 'https://automatisch.io/docs/apps/github/connection', + connectionCount: 1, + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/github/assets/favicon.svg', + key: 'github', + name: 'GitHub', + primaryColor: '#000000', + supportsConnections: true, + }, + { + authDocUrl: 'https://automatisch.io/docs/apps/slack/connection', + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/slack/assets/favicon.svg', + key: 'slack', + name: 'Slack', + primaryColor: '#4a154b', + supportsConnections: true, + }, + { + authDocUrl: 'https://automatisch.io/docs/apps/webhook/connection', + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/webhook/assets/favicon.svg', + key: 'webhook', + name: 'Webhook', + primaryColor: '#0059F7', + supportsConnections: false, + }, + ]; + + return { + data: appsData, + meta: { + count: appsData.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getAppsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js b/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js new file mode 100644 index 0000000..5fc7ab6 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js @@ -0,0 +1,39 @@ +const getCurrentUserMock = (currentUser, role, permissions) => { + return { + data: { + createdAt: currentUser.createdAt.getTime(), + email: currentUser.email, + fullName: currentUser.fullName, + id: currentUser.id, + permissions: permissions.map((permission) => ({ + id: permission.id, + roleId: permission.roleId, + action: permission.action, + subject: permission.subject, + conditions: permission.conditions, + createdAt: permission.createdAt.getTime(), + updatedAt: permission.updatedAt.getTime(), + })), + role: { + createdAt: role.createdAt.getTime(), + description: null, + id: role.id, + isAdmin: role.isAdmin, + name: role.name, + updatedAt: role.updatedAt.getTime(), + }, + status: currentUser.status, + trialExpiryDate: currentUser.trialExpiryDate.toISOString(), + updatedAt: currentUser.updatedAt.getTime(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default getCurrentUserMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-invoices.ee.js b/packages/backend/test/mocks/rest/api/v1/users/get-invoices.ee.js new file mode 100644 index 0000000..a30c995 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/get-invoices.ee.js @@ -0,0 +1,14 @@ +const getInvoicesMock = async (invoices) => { + return { + data: invoices, + meta: { + count: invoices.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getInvoicesMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-subscription.js b/packages/backend/test/mocks/rest/api/v1/users/get-subscription.js new file mode 100644 index 0000000..7c0d3bc --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/get-subscription.js @@ -0,0 +1,27 @@ +const getSubscriptionMock = (subscription) => { + return { + data: { + id: subscription.id, + paddlePlanId: subscription.paddlePlanId, + paddleSubscriptionId: subscription.paddleSubscriptionId, + cancelUrl: subscription.cancelUrl, + updateUrl: subscription.updateUrl, + status: subscription.status, + nextBillAmount: subscription.nextBillAmount, + nextBillDate: subscription.nextBillDate.toISOString(), + lastBillDate: subscription.lastBillDate, + cancellationEffectiveDate: subscription.cancellationEffectiveDate, + createdAt: subscription.createdAt.getTime(), + updatedAt: subscription.updatedAt.getTime(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Subscription', + }, + }; +}; + +export default getSubscriptionMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-user-trial.js b/packages/backend/test/mocks/rest/api/v1/users/get-user-trial.js new file mode 100644 index 0000000..7721aaf --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/get-user-trial.js @@ -0,0 +1,17 @@ +const getUserTrialMock = async (currentUser) => { + return { + data: { + inTrial: await currentUser.inTrial(), + expireAt: currentUser.trialExpiryDate.toISOString(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getUserTrialMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js b/packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js new file mode 100644 index 0000000..bd06bbb --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js @@ -0,0 +1,29 @@ +import appConfig from '../../../../../../src/config/app.js'; + +const registerUserMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + }; + + if (appConfig.isCloud && user.trialExpiryDate) { + userData.trialExpiryDate = user.trialExpiryDate.toISOString(); + } + + return { + data: userData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default registerUserMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/update-current-user-password.js b/packages/backend/test/mocks/rest/api/v1/users/update-current-user-password.js new file mode 100644 index 0000000..e0e6522 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/update-current-user-password.js @@ -0,0 +1,22 @@ +const updateCurrentUserPasswordMock = (currentUser) => { + return { + data: { + createdAt: currentUser.createdAt.getTime(), + email: currentUser.email, + fullName: currentUser.fullName, + id: currentUser.id, + status: currentUser.status, + updatedAt: currentUser.updatedAt.getTime(), + trialExpiryDate: currentUser.trialExpiryDate?.toISOString(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default updateCurrentUserPasswordMock; diff --git a/packages/backend/test/mocks/rest/api/v1/users/update-current-user.js b/packages/backend/test/mocks/rest/api/v1/users/update-current-user.js new file mode 100644 index 0000000..5cba376 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/update-current-user.js @@ -0,0 +1,21 @@ +const updateCurrentUserMock = (currentUser) => { + return { + data: { + createdAt: currentUser.createdAt.getTime(), + email: currentUser.email, + fullName: currentUser.fullName, + id: currentUser.id, + status: currentUser.status, + updatedAt: currentUser.updatedAt.getTime(), + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default updateCurrentUserMock; diff --git a/packages/backend/test/setup/check-env-file.js b/packages/backend/test/setup/check-env-file.js new file mode 100644 index 0000000..1250a63 --- /dev/null +++ b/packages/backend/test/setup/check-env-file.js @@ -0,0 +1,12 @@ +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const testEnvFile = path.resolve(__dirname, '../../.env.test'); + +if (!fs.existsSync(testEnvFile)) { + throw new Error( + 'Test environment file (.env.test) not found! You can copy .env-example.test to .env.test and fill it with your own values.' + ); +} diff --git a/packages/backend/test/setup/global-hooks.js b/packages/backend/test/setup/global-hooks.js new file mode 100644 index 0000000..ad2f97d --- /dev/null +++ b/packages/backend/test/setup/global-hooks.js @@ -0,0 +1,35 @@ +import { Model } from 'objection'; +import { client as knex } from '../../src/config/database.js'; +import logger from '../../src/helpers/logger.js'; +import { vi } from 'vitest'; +import './insert-assertions.js'; + +global.beforeAll(async () => { + global.knex = null; + logger.silent = true; + + // Remove default roles and permissions before running the test suite + await knex.raw('TRUNCATE TABLE config CASCADE'); + await knex.raw('TRUNCATE TABLE roles CASCADE'); + await knex.raw('TRUNCATE TABLE permissions CASCADE'); +}); + +global.beforeEach(async () => { + // It's assigned as global.knex for the convenience even though + // it's a transaction. It's rolled back after each test. + // by assigning to knex, we can use it as knex.table('example') in tests files. + global.knex = await knex.transaction(); + Model.knex(global.knex); +}); + +global.afterEach(async () => { + await global.knex.rollback(); + Model.knex(knex); + + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +global.afterAll(async () => { + logger.silent = false; +}); diff --git a/packages/backend/test/setup/insert-assertions.js b/packages/backend/test/setup/insert-assertions.js new file mode 100644 index 0000000..64664bd --- /dev/null +++ b/packages/backend/test/setup/insert-assertions.js @@ -0,0 +1,8 @@ +import { expect } from 'vitest'; +import { toRequireProperty } from '../assertions/to-require-property'; + +expect.extend({ + async toRequireProperty(model, requiredProperty) { + return await toRequireProperty(model, requiredProperty); + }, +}); diff --git a/packages/backend/test/setup/prepare-test-env.js b/packages/backend/test/setup/prepare-test-env.js new file mode 100644 index 0000000..0fc0b9e --- /dev/null +++ b/packages/backend/test/setup/prepare-test-env.js @@ -0,0 +1,26 @@ +import './check-env-file.js'; +import { createDatabaseAndUser } from '../../bin/database/utils.js'; +import { client as knex } from '../../src/config/database.js'; +import logger from '../../src/helpers/logger.js'; +import appConfig from '../../src/config/app.js'; + +const createAndMigrateDatabase = async () => { + if (!appConfig.CI) { + await createDatabaseAndUser(); + } + + const migrator = knex.migrate; + + await migrator.latest(); + + logger.info(`Completed database migrations for the test database.`); +}; + +createAndMigrateDatabase() + .then(() => { + process.exit(0); + }) + .catch((error) => { + logger.error(error); + process.exit(1); + }); diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js new file mode 100644 index 0000000..9c2ef10 --- /dev/null +++ b/packages/backend/vitest.config.js @@ -0,0 +1,38 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: './', + environment: 'node', + setupFiles: ['./test/setup/global-hooks.js'], + globals: true, + reporters: process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] : ['dot'], + coverage: { + reportOnFailure: true, + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'lcov'], + all: true, + include: [ + '**/src/controllers/**', + '**/src/helpers/authentication.test.js', + '**/src/helpers/axios-with-proxy.test.js', + '**/src/helpers/compute-parameters.test.js', + '**/src/helpers/user-ability.test.js', + '**/src/models/**', + '**/src/serializers/**', + ], + exclude: [ + '**/src/controllers/webhooks/**', + '**/src/controllers/paddle/**', + ], + thresholds: { + autoUpdate: true, + statements: 99.4, + branches: 97.77, + functions: 99.16, + lines: 99.4, + }, + }, + }, +}); diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock new file mode 100644 index 0000000..718eff5 --- /dev/null +++ b/packages/backend/yarn.lock @@ -0,0 +1,4805 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/parser@^7.25.4": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" + integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== + dependencies: + "@babel/types" "^7.26.0" + +"@babel/runtime@^7.15.4": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/types@^7.25.4", "@babel/types@^7.26.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" + integrity sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@bull-board/api@3.11.1": + version "3.11.1" + resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-3.11.1.tgz#98b2c9556f643718bb5bde4a1306e6706af8192e" + integrity sha512-ElwX7sM+Ng4ZL9KUsbDubRE+r2hu/gss85OsROeE9bmyfkW14jOJkgr5MKUyjTTgPEeMs1Mw55TgQs2vxoWBiA== + dependencies: + redis-info "^3.0.8" + +"@bull-board/express@^3.10.1": + version "3.11.1" + resolved "https://registry.yarnpkg.com/@bull-board/express/-/express-3.11.1.tgz#f1285fbc54a5ccdc0028b63ec87a91ac9de878a6" + integrity sha512-+a5IQilu/CxvnGC0z6oJPANn7eF/Q61VU86JR9vRtmaY96mKKny0NuAyulJ2/Y9JznMCBOLUsXWL6fm9zD9kJw== + dependencies: + "@bull-board/api" "3.11.1" + "@bull-board/ui" "3.11.1" + ejs "3.1.7" + express "4.17.3" + +"@bull-board/ui@3.11.1": + version "3.11.1" + resolved "https://registry.yarnpkg.com/@bull-board/ui/-/ui-3.11.1.tgz#17a2af5573f31811a543105b9a96249c95e93ce7" + integrity sha512-SRrfvxHF/WaBICiAFuWAoAlTvoBYUBmX94oRbSKzVILRFZMe3gs0hN071BFohrn4yOTFHAkWPN7cjMbaqHwCag== + dependencies: + "@bull-board/api" "3.11.1" + +"@casl/ability@^6.5.0": + version "6.7.2" + resolved "https://registry.yarnpkg.com/@casl/ability/-/ability-6.7.2.tgz#da727933d8310545db274e02866fad0dda847903" + integrity sha512-KjKXlcjKbUz8dKw7PY56F7qlfOFgxTU6tnlJ8YrbDyWkJMIlHa6VRWzCD8RU20zbJUC1hExhOFggZjm6tf1mUw== + dependencies: + "@ucast/mongo2js" "^1.3.0" + +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + +"@dabh/diagnostics@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" + integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@eslint-community/eslint-utils@^4.2.0": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" + integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.6.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" + integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== + +"@faker-js/faker@^9.2.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.2.0.tgz#269ee3a5d2442e88e10d984e106028422bcb9551" + integrity sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg== + +"@humanwhocodes/config-array@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" + integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== + dependencies: + "@humanwhocodes/object-schema" "^2.0.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@mapbox/node-pre-gyp@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" + integrity sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz#33677a275204898ad8acbf62734fc4dc0b6a4855" + integrity sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz#19edf7cdc2e7063ee328403c1d895a86dd28f4bb" + integrity sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg== + +"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz#94fb0543ba2e28766c3fc439cabbe0440ae70159" + integrity sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw== + +"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz#4a0609ab5fe44d07c9c60a11e4484d3c38bbd6e3" + integrity sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg== + +"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" + integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== + +"@node-saml/node-saml@^4.0.4": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@node-saml/node-saml/-/node-saml-4.0.5.tgz#039e387095b54639b06df62b1b4a6d8941c6d907" + integrity sha512-J5DglElbY1tjOuaR1NPtjOXkXY5bpUhDoKVoeucYN98A3w4fwgjIOPqIGcb6cQsqFq2zZ6vTCeKn5C/hvefSaw== + dependencies: + "@types/debug" "^4.1.7" + "@types/passport" "^1.0.11" + "@types/xml-crypto" "^1.4.2" + "@types/xml-encryption" "^1.2.1" + "@types/xml2js" "^0.4.11" + "@xmldom/xmldom" "^0.8.6" + debug "^4.3.4" + xml-crypto "^3.0.1" + xml-encryption "^3.0.2" + xml2js "^0.5.0" + xmlbuilder "^15.1.1" + +"@node-saml/passport-saml@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@node-saml/passport-saml/-/passport-saml-4.0.4.tgz#dce5ca38828fb2e5f63d56d4c0aefa01ba3c1dbc" + integrity sha512-xFw3gw0yo+K1mzlkW15NeBF7cVpRHN/4vpjmBKzov5YFImCWh/G0LcTZ8krH3yk2/eRPc3Or8LRPudVJBjmYaw== + dependencies: + "@node-saml/node-saml" "^4.0.4" + "@types/express" "^4.17.14" + "@types/passport" "^1.0.11" + "@types/passport-strategy" "^0.2.35" + passport "^0.6.0" + passport-strategy "^1.0.0" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@npmcli/agent@^2.0.0": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5" + integrity sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og== + dependencies: + agent-base "^7.1.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.1" + lru-cache "^10.0.1" + socks-proxy-agent "^8.0.3" + +"@npmcli/fs@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726" + integrity sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg== + dependencies: + semver "^7.3.5" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@rollup/rollup-android-arm-eabi@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.3.tgz#ab2c78c43e4397fba9a80ea93907de7a144f3149" + integrity sha512-EzxVSkIvCFxUd4Mgm4xR9YXrcp976qVaHnqom/Tgm+vU79k4vV4eYTjmRvGfeoW8m9LVcsAy/lGjcgVegKEhLQ== + +"@rollup/rollup-android-arm64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.3.tgz#de840660ab65cf73bd6d4bc62d38acd9fc94cd6c" + integrity sha512-LJc5pDf1wjlt9o/Giaw9Ofl+k/vLUaYsE2zeQGH85giX2F+wn/Cg8b3c5CDP3qmVmeO5NzwVUzQQxwZvC2eQKw== + +"@rollup/rollup-darwin-arm64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.3.tgz#8c786e388f7eff0d830151a9d8fbf04c031bb07f" + integrity sha512-OuRysZ1Mt7wpWJ+aYKblVbJWtVn3Cy52h8nLuNSzTqSesYw1EuN6wKp5NW/4eSre3mp12gqFRXOKTcN3AI3LqA== + +"@rollup/rollup-darwin-x64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.3.tgz#56dab9e4cac0ad97741740ea1ac7b6a576e20e59" + integrity sha512-xW//zjJMlJs2sOrCmXdB4d0uiilZsOdlGQIC/jjmMWT47lkLLoB1nsNhPUcnoqyi5YR6I4h+FjBpILxbEy8JRg== + +"@rollup/rollup-freebsd-arm64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.3.tgz#bcb4112cb7e68a12d148b03cbc21dde43772f4bc" + integrity sha512-58E0tIcwZ+12nK1WiLzHOD8I0d0kdrY/+o7yFVPRHuVGY3twBwzwDdTIBGRxLmyjciMYl1B/U515GJy+yn46qw== + +"@rollup/rollup-freebsd-x64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.3.tgz#c7cd9f69aa43847b37d819f12c2ad6337ec245fa" + integrity sha512-78fohrpcVwTLxg1ZzBMlwEimoAJmY6B+5TsyAZ3Vok7YabRBUvjYTsRXPTjGEvv/mfgVBepbW28OlMEz4w8wGA== + +"@rollup/rollup-linux-arm-gnueabihf@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.3.tgz#3692b22987a6195c8490bbf6357800e0c183ee38" + integrity sha512-h2Ay79YFXyQi+QZKo3ISZDyKaVD7uUvukEHTOft7kh00WF9mxAaxZsNs3o/eukbeKuH35jBvQqrT61fzKfAB/Q== + +"@rollup/rollup-linux-arm-musleabihf@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.3.tgz#f920f24e571f26bbcdb882267086942fdb2474bf" + integrity sha512-Sv2GWmrJfRY57urktVLQ0VKZjNZGogVtASAgosDZ1aUB+ykPxSi3X1nWORL5Jk0sTIIwQiPH7iE3BMi9zGWfkg== + +"@rollup/rollup-linux-arm64-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.3.tgz#2046553e91d8ca73359a2a3bb471826fbbdcc9a3" + integrity sha512-FPoJBLsPW2bDNWjSrwNuTPUt30VnfM8GPGRoLCYKZpPx0xiIEdFip3dH6CqgoT0RnoGXptaNziM0WlKgBc+OWQ== + +"@rollup/rollup-linux-arm64-musl@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.3.tgz#8a3f05dbae753102ae10a9bc2168c7b6bbeea5da" + integrity sha512-TKxiOvBorYq4sUpA0JT+Fkh+l+G9DScnG5Dqx7wiiqVMiRSkzTclP35pE6eQQYjP4Gc8yEkJGea6rz4qyWhp3g== + +"@rollup/rollup-linux-powerpc64le-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.3.tgz#d281d9c762f9e4f1aa7909a313f7acbe78aced32" + integrity sha512-v2M/mPvVUKVOKITa0oCFksnQQ/TqGrT+yD0184/cWHIu0LoIuYHwox0Pm3ccXEz8cEQDLk6FPKd1CCm+PlsISw== + +"@rollup/rollup-linux-riscv64-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.3.tgz#fa84b3f81826cee0de9e90f9954f3e55c3cc6c97" + integrity sha512-LdrI4Yocb1a/tFVkzmOE5WyYRgEBOyEhWYJe4gsDWDiwnjYKjNs7PS6SGlTDB7maOHF4kxevsuNBl2iOcj3b4A== + +"@rollup/rollup-linux-s390x-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.3.tgz#6b9c04d84593836f942ceb4dd90644633d5fe871" + integrity sha512-d4wVu6SXij/jyiwPvI6C4KxdGzuZOvJ6y9VfrcleHTwo68fl8vZC5ZYHsCVPUi4tndCfMlFniWgwonQ5CUpQcA== + +"@rollup/rollup-linux-x64-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.3.tgz#f13effcdcd1cc14b26427e6bec8c6c9e4de3773e" + integrity sha512-/6bn6pp1fsCGEY5n3yajmzZQAh+mW4QPItbiWxs69zskBzJuheb3tNynEjL+mKOsUSFK11X4LYF2BwwXnzWleA== + +"@rollup/rollup-linux-x64-musl@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.3.tgz#6547bc0069f2d788e6cf0f33363b951181f4cca5" + integrity sha512-nBXOfJds8OzUT1qUreT/en3eyOXd2EH5b0wr2bVB5999qHdGKkzGzIyKYaKj02lXk6wpN71ltLIaQpu58YFBoQ== + +"@rollup/rollup-win32-arm64-msvc@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.3.tgz#3f2db9347c5df5e6627a7e12d937cea527d63526" + integrity sha512-ogfbEVQgIZOz5WPWXF2HVb6En+kWzScuxJo/WdQTqEgeyGkaa2ui5sQav9Zkr7bnNCLK48uxmmK0TySm22eiuw== + +"@rollup/rollup-win32-ia32-msvc@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.3.tgz#54fcf9a13a98d3f0e4be6a4b6e28b9dca676502f" + integrity sha512-ecE36ZBMLINqiTtSNQ1vzWc5pXLQHlf/oqGp/bSbi7iedcjcNb6QbCBNG73Euyy2C+l/fn8qKWEwxr+0SSfs3w== + +"@rollup/rollup-win32-x64-msvc@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.3.tgz#3721f601f973059bfeeb572992cf0dfc94ab2970" + integrity sha512-vliZLrDmYKyaUoMzEbMTg2JkerfBjn03KmAw9CykO0Zzkzoyd7o3iZNam/TpyWNjNT+Cz2iO3P9Smv2wgrR+Eg== + +"@rudderstack/rudder-sdk-node@^1.1.2": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-1.1.5.tgz#00ba3e5d3d16d89397703f4eee9213b205dd8cf0" + integrity sha512-grr1lj3Du3FMCrnYreMKmRiuP7mvxftC+04MDD+S4QV1uOeVnFArcEbMUm9LfzfAx5bnhAhfT5DghuXSisKa+w== + dependencies: + "@segment/loosely-validate-event" "^2.0.0" + axios "0.26.0" + axios-retry "^3.2.4" + bull "^4.7.0" + lodash.clonedeep "^4.5.0" + lodash.isstring "^4.0.1" + md5 "^2.3.0" + ms "^2.1.3" + remove-trailing-slash "^0.1.1" + serialize-javascript "^6.0.0" + uuid "^8.3.2" + winston "^3.6.0" + +"@segment/loosely-validate-event@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz#87dfc979e5b4e7b82c5f1d8b722dfd5d77644681" + integrity sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw== + dependencies: + component-type "^1.2.1" + join-component "^1.1.0" + +"@sentry-internal/tracing@7.114.0": + version "7.114.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.114.0.tgz#bdcd364f511e2de45db6e3004faf5685ca2e0f86" + integrity sha512-dOuvfJN7G+3YqLlUY4HIjyWHaRP8vbOgF+OsE5w2l7ZEn1rMAaUbPntAR8AF9GBA6j2zWNoSo8e7GjbJxVofSg== + dependencies: + "@sentry/core" "7.114.0" + "@sentry/types" "7.114.0" + "@sentry/utils" "7.114.0" + +"@sentry-internal/tracing@7.120.0": + version "7.120.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.120.0.tgz#1896e5ecdfc6b238f099f958c4888ab445505227" + integrity sha512-VymJoIGMV0PcTJyshka9uJ1sKpR7bHooqW5jTEr6g0dYAwB723fPXHjVW+7SETF7i5+yr2KMprYKreqRidKyKA== + dependencies: + "@sentry/core" "7.120.0" + "@sentry/types" "7.120.0" + "@sentry/utils" "7.120.0" + +"@sentry/core@7.114.0": + version "7.114.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.114.0.tgz#3efe86b92a5379c44dfd0fd4685266b1a30fa898" + integrity sha512-YnanVlmulkjgZiVZ9BfY9k6I082n+C+LbZo52MTvx3FY6RE5iyiPMpaOh67oXEZRWcYQEGm+bKruRxLVP6RlbA== + dependencies: + "@sentry/types" "7.114.0" + "@sentry/utils" "7.114.0" + +"@sentry/core@7.120.0": + version "7.120.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.120.0.tgz#9be72d6900ceb2ed986c4ceab30f52c6b01e2170" + integrity sha512-uTc2sUQ0heZrMI31oFOHGxjKgw16MbV3C2mcT7qcrb6UmSGR9WqPOXZhnVVuzPWCnQ8B5IPPVdynK//J+9/m6g== + dependencies: + "@sentry/types" "7.120.0" + "@sentry/utils" "7.120.0" + +"@sentry/integrations@7.120.0": + version "7.120.0" + resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.120.0.tgz#59ff04276d62498551154e3e7ecd0f956daff668" + integrity sha512-/Hs9MgSmG4JFNyeQkJ+MWh/fxO/U38Pz0VSH3hDrfyCjI8vH9Vz9inGEQXgB9Ke4eH8XnhsQ7xPnM27lWJts6g== + dependencies: + "@sentry/core" "7.120.0" + "@sentry/types" "7.120.0" + "@sentry/utils" "7.120.0" + localforage "^1.8.1" + +"@sentry/node@^7.42.0": + version "7.120.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.120.0.tgz#260828da2e7e3287d806060c5bc63f34502c3693" + integrity sha512-GAyuNd8WUznsiOyDq2QUwR/aVnMmItUc4tgZQxhH1R+n4Adx3cAhnpq3zEuzsIAC5+/7ut+4Q4B3akh6SDZd4w== + dependencies: + "@sentry-internal/tracing" "7.120.0" + "@sentry/core" "7.120.0" + "@sentry/integrations" "7.120.0" + "@sentry/types" "7.120.0" + "@sentry/utils" "7.120.0" + +"@sentry/tracing@^7.42.0": + version "7.114.0" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.114.0.tgz#32a3438b0f2d02fb7b7359fe7712c5a349a2a329" + integrity sha512-eldEYGADReZ4jWdN5u35yxLUSTOvjsiZAYd4KBEpf+Ii65n7g/kYOKAjNl7tHbrEG1EsMW4nDPWStUMk1w+tfg== + dependencies: + "@sentry-internal/tracing" "7.114.0" + +"@sentry/types@7.114.0": + version "7.114.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.114.0.tgz#ab8009d5f6df23b7342121083bed34ee2452e856" + integrity sha512-tsqkkyL3eJtptmPtT0m9W/bPLkU7ILY7nvwpi1hahA5jrM7ppoU0IMaQWAgTD+U3rzFH40IdXNBFb8Gnqcva4w== + +"@sentry/types@7.120.0": + version "7.120.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.120.0.tgz#63f662d5a5bfb18e3a88e311e2ca736abada642d" + integrity sha512-3mvELhBQBo6EljcRrJzfpGJYHKIZuBXmqh0y8prh03SWE62pwRL614GIYtd4YOC6OP1gfPn8S8h9w3dD5bF5HA== + +"@sentry/utils@7.114.0": + version "7.114.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.114.0.tgz#59d30a79f4acff3c9268de0b345f0bcbc6335112" + integrity sha512-319N90McVpupQ6vws4+tfCy/03AdtsU0MurIE4+W5cubHME08HtiEWlfacvAxX+yuKFhvdsO4K4BB/dj54ideg== + dependencies: + "@sentry/types" "7.114.0" + +"@sentry/utils@7.120.0": + version "7.120.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.120.0.tgz#22a0f3202b2122d6c5a21f4bf509e69622711438" + integrity sha512-XZsPcBHoYu4+HYn14IOnhabUZgCF99Xn4IdWn8Hjs/c+VPtuAVDhRTsfPyPrpY3OcN8DgO5fZX4qcv/6kNbX1A== + dependencies: + "@sentry/types" "7.120.0" + +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/debug@^4.1.7": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/estree@1.0.6", "@types/estree@^1.0.0": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + +"@types/express-serve-static-core@^4.17.33": + version "4.19.6" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz#e01324c2a024ff367d92c66f48553ced0ab50267" + integrity sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express-serve-static-core@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz#3c9997ae9d00bc236e45c6374e84f2596458d9db" + integrity sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@*": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.0.tgz#13a7d1f75295e90d19ed6e74cab3678488eaa96c" + integrity sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/express@^4.17.14": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + +"@types/node@*": + version "22.9.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.0.tgz#b7f16e5c3384788542c72dc3d561a7ceae2c0365" + integrity sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ== + dependencies: + undici-types "~6.19.8" + +"@types/passport-strategy@^0.2.35": + version "0.2.38" + resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3" + integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + +"@types/passport@*", "@types/passport@^1.0.11": + version "1.0.17" + resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6" + integrity sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg== + dependencies: + "@types/express" "*" + +"@types/qs@*": + version "6.9.17" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.17.tgz#fc560f60946d0aeff2f914eb41679659d3310e1a" + integrity sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.7" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714" + integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "*" + +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + +"@types/xml-crypto@^1.4.2": + version "1.4.6" + resolved "https://registry.yarnpkg.com/@types/xml-crypto/-/xml-crypto-1.4.6.tgz#6d1fd7d41c91554f2aed97c2ba273af0388fa5cf" + integrity sha512-A6jEW2FxLZo1CXsRWnZHUX2wzR3uDju2Bozt6rDbSmU/W8gkilaVbwFEVN0/NhnUdMVzwYobWtM6bU1QJJFb7Q== + dependencies: + "@types/node" "*" + xpath "0.0.27" + +"@types/xml-encryption@^1.2.1": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/xml-encryption/-/xml-encryption-1.2.4.tgz#0eceea58c82a89f62c0a2dc383a6461dfc2fe1ba" + integrity sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q== + dependencies: + "@types/node" "*" + +"@types/xml2js@^0.4.11": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.14.tgz#5d462a2a7330345e2309c6b549a183a376de8f9a" + integrity sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ== + dependencies: + "@types/node" "*" + +"@ucast/core@^1.0.0", "@ucast/core@^1.4.1", "@ucast/core@^1.6.1": + version "1.10.2" + resolved "https://registry.yarnpkg.com/@ucast/core/-/core-1.10.2.tgz#30b6b893479823265368e528b61b042f752f2c92" + integrity sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g== + +"@ucast/js@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@ucast/js/-/js-3.0.4.tgz#c57ec2182505c9ad63a5b08ff5911f89ac605262" + integrity sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q== + dependencies: + "@ucast/core" "^1.0.0" + +"@ucast/mongo2js@^1.3.0": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@ucast/mongo2js/-/mongo2js-1.3.4.tgz#579f9e5eb074cba54640d5c70c71c500580f3af3" + integrity sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA== + dependencies: + "@ucast/core" "^1.6.1" + "@ucast/js" "^3.0.0" + "@ucast/mongo" "^2.4.0" + +"@ucast/mongo@^2.4.0": + version "2.4.3" + resolved "https://registry.yarnpkg.com/@ucast/mongo/-/mongo-2.4.3.tgz#92b1dd7c0ab06a907f2ab1422aa3027518ccc05e" + integrity sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA== + dependencies: + "@ucast/core" "^1.4.1" + +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +"@vitest/coverage-v8@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-2.1.5.tgz#74ef3bf6737f9897a54af22f820d90e85883ff83" + integrity sha512-/RoopB7XGW7UEkUndRXF87A9CwkoZAJW01pj8/3pgmDVsjMH2IKy6H1A38po9tmUlwhSyYs0az82rbKd9Yaynw== + dependencies: + "@ampproject/remapping" "^2.3.0" + "@bcoe/v8-coverage" "^0.2.3" + debug "^4.3.7" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-lib-source-maps "^5.0.6" + istanbul-reports "^3.1.7" + magic-string "^0.30.12" + magicast "^0.3.5" + std-env "^3.8.0" + test-exclude "^7.0.1" + tinyrainbow "^1.2.0" + +"@vitest/expect@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.5.tgz#5a6afa6314cae7a61847927bb5bc038212ca7381" + integrity sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q== + dependencies: + "@vitest/spy" "2.1.5" + "@vitest/utils" "2.1.5" + chai "^5.1.2" + tinyrainbow "^1.2.0" + +"@vitest/mocker@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.5.tgz#54ee50648bc0bb606dfc58e13edfacb8b9208324" + integrity sha512-XYW6l3UuBmitWqSUXTNXcVBUCRytDogBsWuNXQijc00dtnU/9OqpXWp4OJroVrad/gLIomAq9aW8yWDBtMthhQ== + dependencies: + "@vitest/spy" "2.1.5" + estree-walker "^3.0.3" + magic-string "^0.30.12" + +"@vitest/pretty-format@2.1.5", "@vitest/pretty-format@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.5.tgz#bc79b8826d4a63dc04f2a75d2944694039fa50aa" + integrity sha512-4ZOwtk2bqG5Y6xRGHcveZVr+6txkH7M2e+nPFd6guSoN638v/1XQ0K06eOpi0ptVU/2tW/pIU4IoPotY/GZ9fw== + dependencies: + tinyrainbow "^1.2.0" + +"@vitest/runner@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.5.tgz#4d5e2ba2dfc0af74e4b0f9f3f8be020559b26ea9" + integrity sha512-pKHKy3uaUdh7X6p1pxOkgkVAFW7r2I818vHDthYLvUyjRfkKOU6P45PztOch4DZarWQne+VOaIMwA/erSSpB9g== + dependencies: + "@vitest/utils" "2.1.5" + pathe "^1.1.2" + +"@vitest/snapshot@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.5.tgz#a09a8712547452a84e08b3ec97b270d9cc156b4f" + integrity sha512-zmYw47mhfdfnYbuhkQvkkzYroXUumrwWDGlMjpdUr4jBd3HZiV2w7CQHj+z7AAS4VOtWxI4Zt4bWt4/sKcoIjg== + dependencies: + "@vitest/pretty-format" "2.1.5" + magic-string "^0.30.12" + pathe "^1.1.2" + +"@vitest/spy@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.5.tgz#f790d1394a5030644217ce73562e92465e83147e" + integrity sha512-aWZF3P0r3w6DiYTVskOYuhBc7EMc3jvn1TkBg8ttylFFRqNN2XGD7V5a4aQdk6QiUzZQ4klNBSpCLJgWNdIiNw== + dependencies: + tinyspy "^3.0.2" + +"@vitest/utils@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.5.tgz#0e19ce677c870830a1573d33ee86b0d6109e9546" + integrity sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg== + dependencies: + "@vitest/pretty-format" "2.1.5" + loupe "^3.1.2" + tinyrainbow "^1.2.0" + +"@xmldom/xmldom@^0.8.5", "@xmldom/xmldom@^0.8.6", "@xmldom/xmldom@^0.8.8": + version "0.8.10" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99" + integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +accounting@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/accounting/-/accounting-0.4.1.tgz#87dd4103eff7f4460f1e186f5c677ed6cf566883" + integrity sha512-RU6KY9Y5wllyaCNBo1W11ZOTnTHMMgOZkIwdOOs6W5ibMTp72i4xIbEA48djxVGqMNTUNbvrP/1nWg5Af5m2gQ== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.9.0: + version "8.14.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" + integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0, ajv@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios-retry@^3.2.4: + version "3.9.1" + resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-3.9.1.tgz#c8924a8781c8e0a2c5244abf773deb7566b3830d" + integrity sha512-8PJDLJv7qTTMMwdnbMvrLYuvB47M81wRtxQmEdV5w4rgbTXTt+vtPkXwajOfOdSyv/wZICJOC+/UhXH4aQ/R+w== + dependencies: + "@babel/runtime" "^7.15.4" + is-retry-allowed "^2.2.0" + +axios@0.26.0: + version "0.26.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.0.tgz#9a318f1c69ec108f8cd5f3c3d390366635e13928" + integrity sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og== + dependencies: + follow-redirects "^1.14.8" + +axios@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" + integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +basic-auth@^2.0.1, basic-auth@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + +bcrypt@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" + integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.11" + node-addon-api "^5.0.0" + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +body-parser@1.19.2: + version "1.19.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.2.tgz#4714ccd9c157d44797b8b5607d72c0b89952f26e" + integrity sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "1.8.1" + iconv-lite "0.4.24" + on-finished "~2.3.0" + qs "6.9.7" + raw-body "2.4.3" + type-is "~1.6.18" + +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +bull@^4.7.0: + version "4.16.4" + resolved "https://registry.yarnpkg.com/bull/-/bull-4.16.4.tgz#0639cea7ca37934f900134440445ce8b52abfe5a" + integrity sha512-CF+nGsJyfsCC9MJL8hFxqXzbwq+jGBXhaz1j15G+5N/XtKIPFUUy5O1mfWWKbKunfuH/x+UV4NYRQDHSkjCOgA== + dependencies: + cron-parser "^4.2.1" + get-port "^5.1.1" + ioredis "^5.3.2" + lodash "^4.17.21" + msgpackr "^1.11.2" + semver "^7.5.2" + uuid "^8.3.0" + +bullmq@^3.0.0: + version "3.16.2" + resolved "https://registry.yarnpkg.com/bullmq/-/bullmq-3.16.2.tgz#a45a23214e9f1ed59aa0ee7f2e94ebf740aff8ce" + integrity sha512-wZIsCdI2H6lza6GdePquCWbrslhYHS7GnDPpP0hzoHkvKiBOt/5jHcsHcHhJi/fob+dfo8dazWKTSLlGLFFqUw== + dependencies: + cron-parser "^4.6.0" + glob "^8.0.3" + ioredis "^5.3.2" + lodash "^4.17.21" + msgpackr "^1.6.2" + semver "^7.3.7" + tslib "^2.0.0" + uuid "^9.0.0" + +busboy@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +cacache@^18.0.0: + version "18.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5" + integrity sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ== + dependencies: + "@npmcli/fs" "^3.1.0" + fs-minipass "^3.0.0" + glob "^10.2.2" + lru-cache "^10.0.1" + minipass "^7.0.3" + minipass-collect "^2.0.1" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + p-map "^4.0.0" + ssri "^10.0.0" + tar "^6.1.11" + unique-filename "^3.0.0" + +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chai@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.2.tgz#3afbc340b994ae3610ca519a6c70ace77ad4378d" + integrity sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +chalk@^4.0.0, chalk@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +charenc@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA== + +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + +color-convert@^1.9.3: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.6.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +color@^3.1.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" + integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== + dependencies: + color-convert "^1.9.3" + color-string "^1.6.0" + +colorette@2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" + integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== + +colorspace@1.1.x: + version "1.1.4" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" + integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== + dependencies: + color "^3.1.3" + text-hex "1.0.x" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^9.0.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" + integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== + +component-emitter@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + +component-type@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/component-type/-/component-type-1.2.2.tgz#4458ecc0c1871efc6288bfaff0cbdab08141d079" + integrity sha512-99VUHREHiN5cLeHm3YLq312p6v+HUEcwtLCAtelvUDI6+SH5g5Cr85oNR2S1o6ywzL0ykMbuwLzM2ANocjEOIA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cron-parser@^4.2.1, cron-parser@^4.6.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.2: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypt@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== + +crypto-js@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +db-errors@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/db-errors/-/db-errors-0.2.3.tgz#a6a38952e00b20e790f2695a6446b3c65497ffa2" + integrity sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng== + +debug@2.6.9, debug@~2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +debug@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + +depd@2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +destroy@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg== + +detect-libc@^2.0.0, detect-libc@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +dotenv@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" + integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +ejs@3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.7.tgz#c544d9c7f715783dd92f0bddcf73a59e6962d006" + integrity sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw== + dependencies: + jake "^10.8.5" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encoding@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +err-code@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" + integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-module-lexer@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" + integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@^1.0.3, escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@^8.3.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz#3a06a662130807e2502fc3ff8b4143d8a0658e11" + integrity sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg== + +eslint-plugin-prettier@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" + integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ== + dependencies: + prettier-linter-helpers "^1.0.0" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint@^8.13.0: + version "8.57.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" + integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.1" + "@humanwhocodes/config-array" "^0.13.0" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +esm@^3.2.25: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esquery@^1.4.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + +expect-type@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.1.0.tgz#a146e414250d13dfc49eafcfd1344a4060fa4c75" + integrity sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA== + +exponential-backoff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" + integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== + +express-async-errors@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/express-async-errors/-/express-async-errors-3.1.1.tgz#6053236d61d21ddef4892d6bd1d736889fc9da41" + integrity sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng== + +express-basic-auth@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/express-basic-auth/-/express-basic-auth-1.2.1.tgz#d31241c03a915dd55db7e5285573049cfcc36381" + integrity sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA== + dependencies: + basic-auth "^2.0.1" + +express@4.17.3: + version "4.17.3" + resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" + integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.19.2" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.4.2" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~1.1.2" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "~1.1.2" + fresh "0.5.2" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.9.7" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.17.2" + serve-static "1.14.2" + setprototypeof "1.2.0" + statuses "~1.5.0" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +express@~4.18.2: + version "4.18.3" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.3.tgz#6870746f3ff904dee1819b82e4b51509afffb0d4" + integrity sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.2" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + +fast-uri@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.3.tgz#892a1c91802d5d7860de728f18608a0573142241" + integrity sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw== + +fast-xml-parser@^4.0.11: + version "4.5.0" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz#2882b7d01a6825dfdf909638f2de0256351def37" + integrity sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg== + dependencies: + strnum "^1.0.5" + +fastq@^1.6.0: + version "1.17.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" + integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + dependencies: + reusify "^1.0.4" + +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +filelist@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +finalhandler@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flatted@^3.2.9: + version "3.3.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" + integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== + +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + +follow-redirects@^1.14.8, follow-redirects@^1.15.0: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + +foreground-child@^3.1.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77" + integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +form-data@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48" + integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +formidable@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" + integrity sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g== + dependencies: + dezalgo "^1.0.4" + hexoid "^1.0.0" + once "^1.4.0" + qs "^6.11.0" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs-minipass@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" + integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw== + dependencies: + minipass "^7.0.3" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-port@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" + integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== + +getopts@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4" + integrity sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA== + +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^10.2.2, glob@^10.3.10, glob@^10.4.1: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^8.0.3: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graceful-fs@^4.2.6: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +handlebars@^4.7.7: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + +hasown@^2.0.0, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +hexoid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +http-cache-semantics@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + +http-errors@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-errors@~1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +https-proxy-agent@^7.0.1: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.2: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +interpret@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" + integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== + +ioredis@^5.3.2: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.1.tgz#1c56b70b759f01465913887375ed809134296f40" + integrity sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + +ip-address@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" + integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== + dependencies: + jsbn "1.1.0" + sprintf-js "^1.1.3" + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-buffer@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-core-module@^2.13.0: + version "2.15.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37" + integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ== + dependencies: + hasown "^2.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-retry-allowed@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz#88f34cbd236e043e71b6932d09b0c65fb7b4d71d" + integrity sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg== + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isexe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" + integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + +isolated-vm@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/isolated-vm/-/isolated-vm-5.0.1.tgz#d87b12cf8889e351cb1598a4aeea00bb458bf20c" + integrity sha512-hs7+ff59Z2zDvavfcjuot/r1gm6Bmpt+GoZxmVfxUmXaX5scOvUq/Rnme+mUtSh5lW41hH8gAuvk/yTJDYO8Fg== + dependencies: + prebuild-install "^7.1.1" + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== + dependencies: + "@jridgewell/trace-mapping" "^0.3.23" + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + +istanbul-reports@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" + integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jake@^10.8.5: + version "10.9.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" + integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== + dependencies: + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.4" + minimatch "^3.1.2" + +join-component@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/join-component/-/join-component-1.1.0.tgz#b8417b750661a392bee2c2537c68b2a9d4977cd5" + integrity sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsbn@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +jsonwebtoken@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +knex@^2.4.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/knex/-/knex-2.5.1.tgz#a6c6b449866cf4229f070c17411f23871ba52ef9" + integrity sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA== + dependencies: + colorette "2.0.19" + commander "^10.0.0" + debug "4.3.4" + escalade "^3.1.1" + esm "^3.2.25" + get-package-type "^0.1.0" + getopts "2.3.0" + interpret "^2.2.0" + lodash "^4.17.21" + pg-connection-string "2.6.1" + rechoir "^0.8.0" + resolve-from "^5.0.0" + tarn "^3.0.2" + tildify "2.0.0" + +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +libphonenumber-js@^1.10.48: + version "1.11.14" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.14.tgz#d753524fd30e6433834a1464baf7efed4a06b593" + integrity sha512-sexvAfwcW1Lqws4zFp8heAtAEXbEDnvkYCEGzvOoMgZR7JhXo/IkE9MkkGACgBed5fWqh3ShBGnJBdDnU9N8EQ== + +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw== + dependencies: + immediate "~3.0.5" + +localforage@^1.8.1: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== + dependencies: + lie "3.1.1" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@^4.17.11, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +logform@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.7.0.tgz#cfca97528ef290f2e125a08396805002b2d060d1" + integrity sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + +loupe@^3.1.0, loupe@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.2.tgz#c86e0696804a02218f2206124c45d8b15291a240" + integrity sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg== + +lru-cache@^10.0.1, lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +luxon@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.5.2.tgz#17ed497f0277e72d58a4756d6a9abee4681457b6" + integrity sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA== + +luxon@^3.2.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + +magic-string@^0.30.12: + version "0.30.13" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.13.tgz#92438e3ff4946cf54f18247c981e5c161c46683c" + integrity sha512-8rYBO+MsWkgjDSOvLomYnzhdwEG51olQ4zL5KXnNJWV5MNmrb4rTZdrtkhxjnD/QyZUqR/Z/XDsUs/4ej2nx0g== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +magicast@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" + integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== + dependencies: + "@babel/parser" "^7.25.4" + "@babel/types" "^7.25.4" + source-map-js "^1.2.0" + +make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +make-fetch-happen@^13.0.0: + version "13.0.1" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36" + integrity sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA== + dependencies: + "@npmcli/agent" "^2.0.0" + cacache "^18.0.0" + http-cache-semantics "^4.1.1" + is-lambda "^1.0.1" + minipass "^7.0.2" + minipass-fetch "^3.0.0" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.3" + proc-log "^4.2.0" + promise-retry "^2.0.1" + ssri "^10.0.0" + +md5@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" + integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== + dependencies: + charenc "0.0.2" + crypt "0.0.2" + is-buffer "~1.1.6" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +memory-cache@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/memory-cache/-/memory-cache-0.2.0.tgz#7890b01d52c00c8ebc9d533e1f8eb17e3034871a" + integrity sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA== + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +methods@^1.1.2, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +minipass-collect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863" + integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw== + dependencies: + minipass "^7.0.3" + +minipass-fetch@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c" + integrity sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg== + dependencies: + minipass "^7.0.3" + minipass-sized "^1.0.3" + minizlib "^2.1.2" + optionalDependencies: + encoding "^0.1.13" + +minipass-flush@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" + integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +minizlib@^2.1.1, minizlib@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +morgan@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" + integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== + dependencies: + basic-auth "~2.0.1" + debug "2.6.9" + depd "~2.0.0" + on-finished "~2.3.0" + on-headers "~1.0.2" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +msgpackr-extract@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz#e9d87023de39ce714872f9e9504e3c1996d61012" + integrity sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA== + dependencies: + node-gyp-build-optional-packages "5.2.2" + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" + +msgpackr@^1.11.2, msgpackr@^1.6.2: + version "1.11.2" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.2.tgz#4463b7f7d68f2e24865c395664973562ad24473d" + integrity sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g== + optionalDependencies: + msgpackr-extract "^3.0.2" + +multer@1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +negotiator@^0.6.3: + version "0.6.4" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" + integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +node-abi@^3.3.0: + version "3.71.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.71.0.tgz#52d84bbcd8575efb71468fbaa1f9a49b2c242038" + integrity sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw== + dependencies: + semver "^7.3.5" + +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-gyp-build-optional-packages@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" + integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== + dependencies: + detect-libc "^2.0.1" + +node-gyp@^10.1.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-10.2.0.tgz#80101c4aa4f7ab225f13fcc8daaaac4eb1a8dd86" + integrity sha512-sp3FonBAaFe4aYTcFdZUn2NYkbP7xroPGYvQmP4Nl5PxamznItBnNCgjrVTKrEfQynInMsJvZrdmqUnysCJ8rw== + dependencies: + env-paths "^2.2.0" + exponential-backoff "^3.1.1" + glob "^10.3.10" + graceful-fs "^4.2.6" + make-fetch-happen "^13.0.0" + nopt "^7.0.0" + proc-log "^4.1.0" + semver "^7.3.5" + tar "^6.2.1" + which "^4.0.0" + +node-html-markdown@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/node-html-markdown/-/node-html-markdown-1.3.0.tgz#ef0b19a3bbfc0f1a880abb9ff2a0c9aa6bbff2a9" + integrity sha512-OeFi3QwC/cPjvVKZ114tzzu+YoR+v9UXW5RwSXGUqGb0qCl0DvP406tzdL7SFn8pZrMyzXoisfG2zcuF9+zw4g== + dependencies: + node-html-parser "^6.1.1" + +node-html-parser@^6.1.1: + version "6.1.13" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-6.1.13.tgz#a1df799b83df5c6743fcd92740ba14682083b7e4" + integrity sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg== + dependencies: + css-select "^5.1.0" + he "1.2.0" + +nodemailer@6.7.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.0.tgz#86614722c4e0c33d1b5b02aecb90d6d629932b0d" + integrity sha512-AtiTVUFHLiiDnMQ43zi0YgkzHOEWUkhDgPlBXrsDzJiJvB29Alo4OKxHQ0ugF3gRqRQIneCLtZU3yiUo7pItZw== + +nodemon@^2.0.13: + version "2.0.22" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.22.tgz#182c45c3a78da486f673d6c1702e00728daf5258" + integrity sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ== + dependencies: + chokidar "^3.5.2" + debug "^3.2.7" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + pstree.remy "^1.1.8" + semver "^5.7.1" + simple-update-notifier "^1.0.7" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.5" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +nopt@^7.0.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +oauth-1.0a@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz#eadbccdb3bceea412d24586e6f39b2b412f0e491" + integrity sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ== + +object-assign@^4, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" + integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== + +objection@^3.0.0: + version "3.1.5" + resolved "https://registry.yarnpkg.com/objection/-/objection-3.1.5.tgz#53c32f6b6cba2958bc28cf723de96c2676da8286" + integrity sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ== + dependencies: + ajv "^8.17.1" + ajv-formats "^2.1.1" + db-errors "^0.2.3" + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + +on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +passport-strategy@1.x.x, passport-strategy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== + +passport@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.6.0.tgz#e869579fab465b5c0b291e841e6cc95c005fac9d" + integrity sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + utils-merge "^1.0.1" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +pathe@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" + integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== + +pathval@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" + integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== + +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== + +pg-cloudflare@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" + integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== + +pg-connection-string@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.1.tgz#78c23c21a35dd116f48e12e23c0965e8d9e2cbfb" + integrity sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg== + +pg-connection-string@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" + integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec" + integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g== + +pg-protocol@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93" + integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^8.7.1: + version "8.13.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" + integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== + dependencies: + pg-connection-string "^2.7.0" + pg-pool "^3.7.0" + pg-protocol "^1.7.0" + pg-types "^2.1.0" + pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + +php-serialize@^4.0.2: + version "4.1.1" + resolved "https://registry.yarnpkg.com/php-serialize/-/php-serialize-4.1.1.tgz#1a614fde3da42361af05afffbaf967fb6556591e" + integrity sha512-7drCrSZdJ05UdG3hyYEIRW0XyKyUFkxa5A3dpIp3NTjUHpI080pkdBAvqaBtkA+kBkMeXX3XnaSnaLGJRz071A== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + +postcss@^8.4.43: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +prebuild-install@^7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.2.tgz#a5fd9986f5a6251fbc47e1e5c65de71e68c0a056" + integrity sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-linter-helpers@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" + integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + dependencies: + fast-diff "^1.1.2" + +prettier@^2.5.1: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +proc-log@^4.1.0, proc-log@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034" + integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +promise-retry@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" + integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== + dependencies: + err-code "^2.0.2" + retry "^0.12.0" + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +pump@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.2.tgz#836f3edd6bc2ee599256c924ffe0d88573ddcbf8" + integrity sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +qs@6.9.7: + version "6.9.7" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe" + integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw== + +qs@^6.11.0: + version "6.13.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.1.tgz#3ce5fc72bd3a8171b85c99b93c65dd20b7d1b16e" + integrity sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg== + dependencies: + side-channel "^1.0.6" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.3.tgz#8f80305d11c2a0a545c2d9d89d7a0286fcead43c" + integrity sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g== + dependencies: + bytes "3.1.2" + http-errors "1.8.1" + iconv-lite "0.4.24" + unpipe "1.0.0" + +raw-body@2.5.2, raw-body@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0, readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +rechoir@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" + integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== + dependencies: + resolve "^1.20.0" + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-info@^3.0.8: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redis-info/-/redis-info-3.1.0.tgz#5e349c8720e82d27ac84c73136dce0931e10469a" + integrity sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg== + dependencies: + lodash "^4.17.11" + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +remove-trailing-slash@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz#be2285a59f39c74d1bce4f825950061915e3780d" + integrity sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.20.0: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rollup@^4.20.0: + version "4.27.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.27.3.tgz#078ecb20830c1de1f5486607f3e2f490269fb98a" + integrity sha512-SLsCOnlmGt9VoZ9Ek8yBK8tAdmPHeppkw+Xa7yDlCEhDTvwYei03JlWo1fdc7YTfLZ4tD8riJCUyAgTbszk1fQ== + dependencies: + "@types/estree" "1.0.6" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.27.3" + "@rollup/rollup-android-arm64" "4.27.3" + "@rollup/rollup-darwin-arm64" "4.27.3" + "@rollup/rollup-darwin-x64" "4.27.3" + "@rollup/rollup-freebsd-arm64" "4.27.3" + "@rollup/rollup-freebsd-x64" "4.27.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.27.3" + "@rollup/rollup-linux-arm-musleabihf" "4.27.3" + "@rollup/rollup-linux-arm64-gnu" "4.27.3" + "@rollup/rollup-linux-arm64-musl" "4.27.3" + "@rollup/rollup-linux-powerpc64le-gnu" "4.27.3" + "@rollup/rollup-linux-riscv64-gnu" "4.27.3" + "@rollup/rollup-linux-s390x-gnu" "4.27.3" + "@rollup/rollup-linux-x64-gnu" "4.27.3" + "@rollup/rollup-linux-x64-musl" "4.27.3" + "@rollup/rollup-win32-arm64-msvc" "4.27.3" + "@rollup/rollup-win32-ia32-msvc" "4.27.3" + "@rollup/rollup-win32-x64-msvc" "4.27.3" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sax@1.2.x: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +sax@>=0.6.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" + integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== + +semver@^5.7.1: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.0.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + +semver@~7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== + +send@0.17.2: + version "0.17.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820" + integrity sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww== + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "1.8.1" + mime "1.6.0" + ms "2.1.3" + on-finished "~2.3.0" + range-parser "~1.2.1" + statuses "~1.5.0" + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serialize-javascript@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +serve-static@1.14.2: + version "1.14.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.2.tgz#722d6294b1d62626d41b43a013ece4598d292bfa" + integrity sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.17.2" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +showdown@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/showdown/-/showdown-2.1.0.tgz#1251f5ed8f773f0c0c7bfc8e6fd23581f9e545c5" + integrity sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ== + dependencies: + commander "^9.0.0" + +side-channel@^1.0.4, side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^3.0.0: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + +simple-update-notifier@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz#67694c121de354af592b347cdba798463ed49c82" + integrity sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg== + dependencies: + semver "~7.0.0" + +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^8.0.3: + version "8.0.4" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz#9071dca17af95f483300316f4b063578fa0db08c" + integrity sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw== + dependencies: + agent-base "^7.1.1" + debug "^4.3.4" + socks "^2.8.3" + +socks@^2.8.3: + version "2.8.3" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5" + integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw== + dependencies: + ip-address "^9.0.5" + smart-buffer "^4.2.0" + +source-map-js@^1.2.0, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + +ssri@^10.0.0: + version "10.0.6" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5" + integrity sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ== + dependencies: + minipass "^7.0.3" + +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +std-env@^3.8.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.0.tgz#b56ffc1baf1a29dcc80a3bdf11d7fca7c315e7d5" + integrity sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== + +strnum@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" + integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + +superagent@^8.1.2: + version "8.1.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" + integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.4" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^2.1.2" + methods "^1.1.2" + mime "2.6.0" + qs "^6.11.0" + semver "^7.3.8" + +supertest@^6.3.3: + version "6.3.4" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.4.tgz#2145c250570c2ea5d337db3552dbfb78a2286218" + integrity sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw== + dependencies: + methods "^1.1.2" + superagent "^8.1.2" + +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar@^6.1.11, tar@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + +tarn@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693" + integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ== + +test-exclude@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" + integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^10.4.1" + minimatch "^9.0.4" + +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +tildify@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" + integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.1.tgz#0ab0daf93b43e2c211212396bdb836b468c97c98" + integrity sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ== + +tinypool@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" + integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== + +tinyrainbow@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" + integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== + +tinyspy@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a" + integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + +tslib@^2.0.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-is@^1.6.4, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +undici-types@~6.19.8: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +unique-filename@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" + integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g== + dependencies: + unique-slug "^4.0.0" + +unique-slug@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3" + integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ== + dependencies: + imurmurhash "^0.1.4" + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1, utils-merge@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^8.3.0, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +uuid@^9.0.0, uuid@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +vite-node@2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.5.tgz#cf28c637b2ebe65921f3118a165b7cf00a1cdf19" + integrity sha512-rd0QIgx74q4S1Rd56XIiL2cYEdyWn13cunYBIuqh9mpmQr7gGS0IxXoP8R6OaZtNQQLyXSWbd4rXKYUbhFpK5w== + dependencies: + cac "^6.7.14" + debug "^4.3.7" + es-module-lexer "^1.5.4" + pathe "^1.1.2" + vite "^5.0.0" + +vite@^5.0.0: + version "5.4.11" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" + integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.5.tgz#a93b7b84a84650130727baae441354e6df118148" + integrity sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A== + dependencies: + "@vitest/expect" "2.1.5" + "@vitest/mocker" "2.1.5" + "@vitest/pretty-format" "^2.1.5" + "@vitest/runner" "2.1.5" + "@vitest/snapshot" "2.1.5" + "@vitest/spy" "2.1.5" + "@vitest/utils" "2.1.5" + chai "^5.1.2" + debug "^4.3.7" + expect-type "^1.1.0" + magic-string "^0.30.12" + pathe "^1.1.2" + std-env "^3.8.0" + tinybench "^2.9.0" + tinyexec "^0.3.1" + tinypool "^1.0.1" + tinyrainbow "^1.2.0" + vite "^5.0.0" + vite-node "2.1.5" + why-is-node-running "^2.3.0" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +which@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" + integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== + dependencies: + isexe "^3.1.1" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +winston-transport@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9" + integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A== + dependencies: + logform "^2.7.0" + readable-stream "^3.6.2" + triple-beam "^1.3.0" + +winston@^3.6.0, winston@^3.7.1: + version "3.17.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423" + integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.7.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.9.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +xml-crypto@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-3.2.0.tgz#a9debab572c8e895cff5fb351a8d8be3f6e1962e" + integrity sha512-qVurBUOQrmvlgmZqIVBqmb06TD2a/PpEUfFPgD7BuBfjmoH4zgkqaWSIJrnymlCvM2GGt9x+XtJFA+ttoAufqg== + dependencies: + "@xmldom/xmldom" "^0.8.8" + xpath "0.0.32" + +xml-encryption@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-3.0.2.tgz#d3cb67d97cdd9673313a42cc0d7fa43ff0886c21" + integrity sha512-VxYXPvsWB01/aqVLd6ZMPWZ+qaj0aIdF+cStrVJMcFj3iymwZeI0ABzB3VqMYv48DkSpRhnrXqTUkR34j+UDyg== + dependencies: + "@xmldom/xmldom" "^0.8.5" + escape-html "^1.0.3" + xpath "0.0.32" + +xml2js@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7" + integrity sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@8.2.x: + version "8.2.2" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773" + integrity sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw== + +xmlbuilder@^15.1.1: + version "15.1.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" + integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +xmlrpc@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/xmlrpc/-/xmlrpc-1.3.2.tgz#26b2ea347848d028aac7e7514b5351976de3e83d" + integrity sha512-jQf5gbrP6wvzN71fgkcPPkF4bF/Wyovd7Xdff8d6/ihxYmgETQYSuTc+Hl+tsh/jmgPLro/Aro48LMFlIyEKKQ== + dependencies: + sax "1.2.x" + xmlbuilder "8.2.x" + +xpath@0.0.27: + version "0.0.27" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92" + integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== + +xpath@0.0.32: + version "0.0.32" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" + integrity sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/packages/docs/.gitignore b/packages/docs/.gitignore new file mode 100644 index 0000000..bf0660a --- /dev/null +++ b/packages/docs/.gitignore @@ -0,0 +1 @@ +pages/.vitepress/cache diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 0000000..54de19f --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,32 @@ +{ + "name": "@automatisch/docs", + "version": "0.10.0", + "license": "See LICENSE file", + "description": "The open source Zapier alternative. Build workflow automation without spending time and money.", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev pages --port 3002", + "build": "vitepress build pages", + "serve": "vitepress serve pages" + }, + "devDependencies": { + "sitemap": "^7.1.1", + "vitepress": "^1.0.0-alpha.21", + "vue": "^3.2.37" + }, + "contributors": [ + { + "name": "automatisch contributors", + "url": "https://github.com/automatisch/automatisch/graphs/contributors" + } + ], + "homepage": "https://github.com/automatisch/automatisch#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/automatisch/automatisch.git" + }, + "bugs": { + "url": "https://github.com/automatisch/automatisch/issues" + } +} diff --git a/packages/docs/pages/.vitepress/config.js b/packages/docs/pages/.vitepress/config.js new file mode 100644 index 0000000..c842386 --- /dev/null +++ b/packages/docs/pages/.vitepress/config.js @@ -0,0 +1,858 @@ +import { defineConfig } from 'vitepress'; +import { createWriteStream } from 'fs'; +import { resolve } from 'path'; +import { SitemapStream } from 'sitemap'; + +const BASE = process.env.BASE_URL || '/'; + +const links = []; +const PROD_BASE_URL = 'https://automatisch.io/docs'; + +export default defineConfig({ + base: BASE, + lang: 'en-US', + title: 'Automatisch Docs', + description: + 'Build workflow automation without spending time and money. No code is required.', + cleanUrls: 'with-subfolders', + ignoreDeadLinks: true, + themeConfig: { + siteTitle: 'Automatisch', + nav: [ + { + text: 'Guide', + link: '/', + activeMatch: '^/$|^/guide/', + }, + { + text: 'Apps', + link: '/apps/airtable/connection', + activeMatch: '/apps/', + }, + ], + sidebar: { + '/apps/': [ + { + text: 'Airtable', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/airtable/actions' }, + { text: 'Connection', link: '/apps/airtable/connection' }, + ], + }, + { + text: 'Anthropic', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/anthropic/actions' }, + { text: 'Connection', link: '/apps/anthropic/connection' }, + ], + }, + { + text: 'Appwrite', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/appwrite/triggers' }, + { text: 'Connection', link: '/apps/appwrite/connection' }, + ], + }, + { + text: 'Brave Search', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/brave-search/actions' }, + { text: 'Connection', link: '/apps/brave-search/connection' }, + ], + }, + { + text: 'Carbone', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/carbone/actions' }, + { text: 'Connection', link: '/apps/carbone/connection' }, + ], + }, + { + text: 'ClickUp', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/clickup/actions' }, + { text: 'Triggers', link: '/apps/clickup/triggers' }, + { text: 'Connection', link: '/apps/clickup/connection' }, + ], + }, + { + text: 'Cryptography', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/cryptography/actions' }, + { text: 'Connection', link: '/apps/cryptography/connection' }, + ], + }, + { + text: 'Datastore', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/datastore/actions' }, + { text: 'Connection', link: '/apps/datastore/connection' }, + ], + }, + { + text: 'DeepL', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/deepl/actions' }, + { text: 'Connection', link: '/apps/deepl/connection' }, + ], + }, + { + text: 'Delay', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/delay/actions' }, + { text: 'Connection', link: '/apps/delay/connection' }, + ], + }, + { + text: 'Discord', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/discord/actions' }, + { text: 'Connection', link: '/apps/discord/connection' }, + ], + }, + { + text: 'Disqus', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/disqus/triggers' }, + { text: 'Connection', link: '/apps/disqus/connection' }, + ], + }, + { + text: 'Dropbox', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/dropbox/actions' }, + { text: 'Connection', link: '/apps/dropbox/connection' }, + ], + }, + { + text: 'Filter', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/filter/actions' }, + { text: 'Connection', link: '/apps/filter/connection' }, + ], + }, + { + text: 'Flickr', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/flickr/triggers' }, + { text: 'Connection', link: '/apps/flickr/connection' }, + ], + }, + { + text: 'Formatter', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/formatter/actions' }, + { text: 'Connection', link: '/apps/formatter/connection' }, + ], + }, + { + text: 'Freescout', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/freescout/triggers' }, + { text: 'Connection', link: '/apps/freescout/connection' }, + ], + }, + { + text: 'Ghost', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/ghost/triggers' }, + { text: 'Connection', link: '/apps/ghost/connection' }, + ], + }, + { + text: 'GitHub', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/github/triggers' }, + { text: 'Actions', link: '/apps/github/actions' }, + { text: 'Connection', link: '/apps/github/connection' }, + ], + }, + { + text: 'GitLab', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/gitlab/triggers' }, + { text: 'Connection', link: '/apps/gitlab/connection' }, + ], + }, + { + text: 'Google Calendar', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/google-calendar/triggers' }, + { text: 'Connection', link: '/apps/google-calendar/connection' }, + ], + }, + { + text: 'Google Drive', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/google-drive/triggers' }, + { text: 'Connection', link: '/apps/google-drive/connection' }, + ], + }, + { + text: 'Google Forms', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/google-forms/triggers' }, + { text: 'Connection', link: '/apps/google-forms/connection' }, + ], + }, + { + text: 'Google Sheets', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/google-sheets/triggers' }, + { text: 'Actions', link: '/apps/google-sheets/actions' }, + { text: 'Connection', link: '/apps/google-sheets/connection' }, + ], + }, + { + text: 'Google Tasks', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/google-tasks/triggers' }, + { text: 'Actions', link: '/apps/google-tasks/actions' }, + { text: 'Connection', link: '/apps/google-tasks/connection' }, + ], + }, + { + text: 'HTTP Request', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/http-request/actions' }, + { text: 'Connection', link: '/apps/http-request/connection' }, + ], + }, + { + text: 'HubSpot', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/hubspot/actions' }, + { text: 'Connection', link: '/apps/hubspot/connection' }, + ], + }, + { + text: 'Invoice Ninja', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/invoice-ninja/triggers' }, + { text: 'Actions', link: '/apps/invoice-ninja/actions' }, + { text: 'Connection', link: '/apps/invoice-ninja/connection' }, + ], + }, + { + text: 'Jotform', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/jotform/triggers' }, + { text: 'Connection', link: '/apps/jotform/connection' }, + ], + }, + { + text: 'Mailchimp', + collapsible: true, + collapsed: true, + items: [{ text: 'Connection', link: '/apps/mailchimp/connection' }], + }, + { + text: 'MailerLite', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/mailerlite/triggers' }, + { text: 'Connection', link: '/apps/mailerlite/connection' }, + ], + }, + { + text: 'Mattermost', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/mattermost/actions' }, + { text: 'Connection', link: '/apps/mattermost/connection' }, + ], + }, + { + text: 'Miro', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/miro/actions' }, + { text: 'Connection', link: '/apps/miro/connection' }, + ], + }, + { + text: 'Mistral AI', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/mistral-ai/actions' }, + { text: 'Connection', link: '/apps/mistral-ai/connection' }, + ], + }, + { + text: 'Notion', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/notion/triggers' }, + { text: 'Actions', link: '/apps/notion/actions' }, + { text: 'Connection', link: '/apps/notion/connection' }, + ], + }, + { + text: 'Ntfy', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/ntfy/actions' }, + { text: 'Connection', link: '/apps/ntfy/connection' }, + ], + }, + { + text: 'Odoo', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/odoo/actions' }, + { text: 'Connection', link: '/apps/odoo/connection' }, + ], + }, + { + text: 'OpenAI', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/openai/actions' }, + { text: 'Connection', link: '/apps/openai/connection' }, + ], + }, + { + text: 'OpenRouter', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/openrouter/actions' }, + { text: 'Connection', link: '/apps/openrouter/connection' }, + ], + }, + { + text: 'Perplexity', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/perplexity/actions' }, + { text: 'Connection', link: '/apps/perplexity/connection' }, + ], + }, + { + text: 'Pipedrive', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/pipedrive/triggers' }, + { text: 'Actions', link: '/apps/pipedrive/actions' }, + { text: 'Connection', link: '/apps/pipedrive/connection' }, + ], + }, + { + text: 'Placetel', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/placetel/triggers' }, + { text: 'Connection', link: '/apps/placetel/connection' }, + ], + }, + { + text: 'PostgreSQL', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/postgresql/actions' }, + { text: 'Connection', link: '/apps/postgresql/connection' }, + ], + }, + { + text: 'Pushover', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/pushover/actions' }, + { text: 'Connection', link: '/apps/pushover/connection' }, + ], + }, + { + text: 'Reddit', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/reddit/triggers' }, + { text: 'Actions', link: '/apps/reddit/actions' }, + { text: 'Connection', link: '/apps/reddit/connection' }, + ], + }, + { + text: 'Remove.bg', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/removebg/actions' }, + { text: 'Connection', link: '/apps/removebg/connection' }, + ], + }, + { + text: 'RSS', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/rss/triggers' }, + { text: 'Connection', link: '/apps/rss/connection' }, + ], + }, + { + text: 'Salesforce', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/salesforce/triggers' }, + { text: 'Actions', link: '/apps/salesforce/actions' }, + { text: 'Connection', link: '/apps/salesforce/connection' }, + ], + }, + { + text: 'Scheduler', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/scheduler/triggers' }, + { text: 'Connection', link: '/apps/scheduler/connection' }, + ], + }, + { + text: 'SignalWire', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/signalwire/triggers' }, + { text: 'Actions', link: '/apps/signalwire/actions' }, + { text: 'Connection', link: '/apps/signalwire/connection' }, + ], + }, + { + text: 'Slack', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/slack/actions' }, + { text: 'Connection', link: '/apps/slack/connection' }, + ], + }, + { + text: 'SMTP', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/smtp/actions' }, + { text: 'Connection', link: '/apps/smtp/connection' }, + ], + }, + { + text: 'Spotify', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/spotify/actions' }, + { text: 'Connection', link: '/apps/spotify/connection' }, + ], + }, + { + text: 'Strava', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/strava/actions' }, + { text: 'Connection', link: '/apps/strava/connection' }, + ], + }, + { + text: 'Stripe', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/stripe/triggers' }, + { text: 'Connection', link: '/apps/stripe/connection' }, + ], + }, + { + text: 'Telegram', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/telegram-bot/actions' }, + { text: 'Connection', link: '/apps/telegram-bot/connection' }, + ], + }, + { + text: 'Todoist', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/todoist/triggers' }, + { text: 'Actions', link: '/apps/todoist/actions' }, + { text: 'Connection', link: '/apps/todoist/connection' }, + ], + }, + { + text: 'Together AI', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/together-ai/actions' }, + { text: 'Connection', link: '/apps/together-ai/connection' }, + ], + }, + { + text: 'Trello', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/trello/actions' }, + { text: 'Connection', link: '/apps/trello/connection' }, + ], + }, + { + text: 'Twilio', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/twilio/triggers' }, + { text: 'Actions', link: '/apps/twilio/actions' }, + { text: 'Connection', link: '/apps/twilio/connection' }, + ], + }, + { + text: 'Twitter', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/twitter/triggers' }, + { text: 'Actions', link: '/apps/twitter/actions' }, + { text: 'Connection', link: '/apps/twitter/connection' }, + ], + }, + { + text: 'Typeform', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/typeform/triggers' }, + { text: 'Connection', link: '/apps/typeform/connection' }, + ], + }, + { + text: 'VirtualQ', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/virtualq/actions' }, + { text: 'Connection', link: '/apps/virtualq/connection' }, + ], + }, + { + text: 'Vtiger CRM', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/vtiger-crm/triggers' }, + { text: 'Actions', link: '/apps/vtiger-crm/actions' }, + { text: 'Connection', link: '/apps/vtiger-crm/connection' }, + ], + }, + { + text: 'Webhooks', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/webhooks/triggers' }, + { text: 'Connection', link: '/apps/webhooks/connection' }, + ], + }, + { + text: 'WordPress', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/wordpress/triggers' }, + { text: 'Connection', link: '/apps/wordpress/connection' }, + ], + }, + { + text: 'Xero', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/xero/triggers' }, + { text: 'Connection', link: '/apps/xero/connection' }, + ], + }, + { + text: 'You Need A Budget', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/you-need-a-budget/triggers' }, + { text: 'Connection', link: '/apps/you-need-a-budget/connection' }, + ], + }, + { + text: 'Youtube', + collapsible: true, + collapsed: true, + items: [ + { text: 'Triggers', link: '/apps/youtube/triggers' }, + { text: 'Connection', link: '/apps/youtube/connection' }, + ], + }, + { + text: 'Zendesk', + collapsible: true, + collapsed: true, + items: [ + { text: 'Actions', link: '/apps/zendesk/actions' }, + { text: 'Connection', link: '/apps/zendesk/connection' }, + ], + }, + ], + '/': [ + { + text: 'Getting Started', + collapsible: true, + items: [ + { + text: 'What is Automatisch?', + link: '/', + activeMatch: '/', + }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Key concepts', link: '/guide/key-concepts' }, + { text: 'Create flow', link: '/guide/create-flow' }, + ], + }, + { + text: 'Integrations', + collapsible: true, + items: [ + { text: 'Available apps', link: '/guide/available-apps' }, + { + text: 'Request integration', + link: '/guide/request-integration', + }, + ], + }, + { + text: 'Advanced', + collapsible: true, + items: [ + { text: 'Configuration', link: '/advanced/configuration' }, + { text: 'Credentials', link: '/advanced/credentials' }, + { text: 'Telemetry', link: '/advanced/telemetry' }, + ], + }, + { + text: 'Contributing', + collapsible: true, + items: [ + { + text: 'Contribution guide', + link: '/contributing/contribution-guide', + }, + { + text: 'Development setup', + link: '/contributing/development-setup', + }, + { + text: 'Repository structure', + link: '/contributing/repository-structure', + }, + ], + }, + { + text: 'Build Integrations', + collapsible: true, + items: [ + { + text: 'Folder structure', + link: '/build-integrations/folder-structure', + }, + { + text: 'App', + link: '/build-integrations/app', + }, + { + text: 'Global variable', + link: '/build-integrations/global-variable', + }, + { + text: 'Auth', + link: '/build-integrations/auth', + }, + { + text: 'Triggers', + link: '/build-integrations/triggers', + }, + { + text: 'Actions', + link: '/build-integrations/actions', + }, + { + text: 'Examples', + link: '/build-integrations/examples', + }, + ], + }, + { + text: 'Other', + collapsible: true, + items: [ + { text: 'License', link: '/other/license' }, + { text: 'Community', link: '/other/community' }, + ], + }, + ], + }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/automatisch/automatisch' }, + { icon: 'twitter', link: 'https://twitter.com/automatischio' }, + { icon: 'discord', link: 'https://discord.gg/dJSah9CVrC' }, + ], + editLink: { + pattern: + 'https://github.com/automatisch/automatisch/edit/main/packages/docs/pages/:path', + text: 'Edit this page on GitHub', + }, + footer: { + copyright: 'Copyright © 2022 Automatisch. All rights reserved.', + }, + algolia: { + appId: 'I7I8MRYC3P', + apiKey: '9325eb970bdd6a70b1e35528b39ed2fe', + indexName: 'automatisch', + }, + }, + + async transformHead(ctx) { + if (ctx.pageData.relativePath === '') return; // Skip 404 page. + + const isHomepage = ctx.pageData.relativePath === 'index.md'; + let canonicalUrl = PROD_BASE_URL; + + if (!isHomepage) { + canonicalUrl = + `${canonicalUrl}/` + ctx.pageData.relativePath.replace('.md', ''); + } + + // Added for logging purposes to check if there is something + // wrong with the canonical URL in the deployment pipeline. + console.log(''); + console.log('File path: ', ctx.pageData.relativePath); + console.log('Canonical URL: ', canonicalUrl); + + return [ + [ + 'link', + { + rel: 'canonical', + href: canonicalUrl, + }, + ], + [ + 'script', + { + defer: true, + 'data-domain': 'automatisch.io', + 'data-api': 'https://automatisch.io/data/api/event', + src: 'https://automatisch.io/data/js/script.js', + }, + ], + ]; + }, + + async transformHtml(_, id, { pageData }) { + if (!/[\\/]404\.html$/.test(id)) { + let url = pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2'); + + const isHomepage = url === ''; + + if (isHomepage) { + url = '/docs'; + } + + links.push({ + url, + lastmod: pageData.lastUpdated, + }); + } + }, + + async buildEnd({ outDir }) { + const sitemap = new SitemapStream({ + hostname: `${PROD_BASE_URL}/`, + }); + + const writeStream = createWriteStream(resolve(outDir, 'sitemap.xml')); + sitemap.pipe(writeStream); + links.forEach((link) => sitemap.write(link)); + sitemap.end(); + }, +}); diff --git a/packages/docs/pages/.vitepress/theme/CustomLayout.vue b/packages/docs/pages/.vitepress/theme/CustomLayout.vue new file mode 100644 index 0000000..a2d3914 --- /dev/null +++ b/packages/docs/pages/.vitepress/theme/CustomLayout.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/docs/pages/.vitepress/theme/custom.css b/packages/docs/pages/.vitepress/theme/custom.css new file mode 100644 index 0000000..8ac3296 --- /dev/null +++ b/packages/docs/pages/.vitepress/theme/custom.css @@ -0,0 +1,149 @@ +/** + * Colors + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-brand: #0059f7; + --vp-c-brand-light: #4789ff; + --vp-c-brand-lighter: #7eacff; + --vp-c-brand-lightest: #a5c5ff; + --vp-c-brand-dark: #001f52; + --vp-c-brand-darker: #001639; + --vp-c-brand-dimm: rgba(100, 108, 255, 0.08); +} + +/** + * Component: Button + * -------------------------------------------------------------------------- */ + +:root { + --vp-button-brand-border: var(--vp-c-brand-light); + --vp-button-brand-text: var(--vp-c-text-dark-1); + --vp-button-brand-bg: var(--vp-c-brand); + --vp-button-brand-hover-border: var(--vp-c-brand-light); + --vp-button-brand-hover-text: var(--vp-c-text-dark-1); + --vp-button-brand-hover-bg: var(--vp-c-brand-light); + --vp-button-brand-active-border: var(--vp-c-brand-light); + --vp-button-brand-active-text: var(--vp-c-text-dark-1); + --vp-button-brand-active-bg: var(--vp-button-brand-bg); +} + +/** + * Component: Home + * -------------------------------------------------------------------------- */ + +:root { + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient( + 120deg, + #bd34fe 30%, + #41d1ff + ); + + --vp-home-hero-image-background-image: linear-gradient( + -45deg, + #bd34fe 50%, + #47caff 50% + ); + --vp-home-hero-image-filter: blur(40px); +} + +@media (min-width: 640px) { + :root { + --vp-home-hero-image-filter: blur(56px); + } +} + +@media (min-width: 960px) { + :root { + --vp-home-hero-image-filter: blur(72px); + } +} + +/** + * Component: Custom Block + * -------------------------------------------------------------------------- */ + +:root { + --vp-custom-block-tip-border: var(--vp-c-brand); + --vp-custom-block-tip-text: var(--vp-c-brand-darker); + --vp-custom-block-tip-bg: var(--vp-c-brand-dimm); +} + +.dark { + --vp-custom-block-tip-border: var(--vp-c-brand); + --vp-custom-block-tip-text: var(--vp-c-brand-lightest); + --vp-custom-block-tip-bg: var(--vp-c-brand-dimm); +} + +/** + * Component: Algolia + * -------------------------------------------------------------------------- */ + +.DocSearch { + --docsearch-primary-color: var(--vp-c-brand) !important; +} + +/** + * VitePress: Custom fix + * -------------------------------------------------------------------------- */ + +/* + Use lighter colors for links in dark mode for a11y. + Also specify some classes twice to have higher specificity + over scoped class data attribute. +*/ +.dark .vp-doc a, +.dark .vp-doc a > code, +.dark .VPNavBarMenuLink.VPNavBarMenuLink:hover, +.dark .VPNavBarMenuLink.VPNavBarMenuLink.active, +.dark .link.link:hover, +.dark .link.link.active, +.dark .edit-link-button.edit-link-button, +.dark .pager-link .title { + color: var(--vp-c-brand-lighter); +} + +.dark .vp-doc a:hover, +.dark .vp-doc a > code:hover { + color: var(--vp-c-brand-lightest); + opacity: 1; +} + +/* Transition by color instead of opacity */ +.dark .vp-doc .custom-block a { + transition: color 0.25s; +} + +:root { + overflow-y: scroll; + + --announcement-bar-height: 50px; +} + +.VPTeamMembersItem .avatar-img { + top: 50%; + transform: translateY(-50%); +} + +header.VPNav { + margin-top: 50px; +} + +.VPNavScreen.VPNavScreen { + top: calc(var(--announcement-bar-height) + var(--vp-nav-height-mobile)); +} + +.VPLocalNav.VPLocalNav { + top: 50px; +} + +aside.VPSidebar { + margin-top: 50px; +} + +@media (min-width: 960px) { + #VPContent { + margin-top: 50px; + } +} diff --git a/packages/docs/pages/.vitepress/theme/index.js b/packages/docs/pages/.vitepress/theme/index.js new file mode 100644 index 0000000..75eaece --- /dev/null +++ b/packages/docs/pages/.vitepress/theme/index.js @@ -0,0 +1,8 @@ +import DefaultTheme from 'vitepress/theme'; +import './custom.css'; +import CustomLayout from './CustomLayout.vue'; + +export default { + ...DefaultTheme, + Layout: CustomLayout, +}; diff --git a/packages/docs/pages/advanced/configuration.md b/packages/docs/pages/advanced/configuration.md new file mode 100644 index 0000000..aa46156 --- /dev/null +++ b/packages/docs/pages/advanced/configuration.md @@ -0,0 +1,47 @@ +# Configuration + +## How to set environment variables? + +You can modify the `docker-compose.yml` file to override environment variables. Please do not forget to change in `main` and `worker` services of docker-compose since the following variables might be used in both. + +## Environment Variables + +:::warning +The default values for some environment variables might be different in our development setup but following table shows the default values for docker-compose setup, which is the recommended way to run the application. +::: + +:::danger +Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment variables. They are used to encrypt your credentials from third-party services and verify webhook requests. If you change them, your existing connections and flows will not continue to work. +::: + +| Variable Name | Type | Default Value | Description | +| ---------------------------- | ------- | ------------------ | ----------------------------------------------------------------------------------- | +| `HOST` | string | `localhost` | HTTP Host | +| `PROTOCOL` | string | `http` | HTTP Protocol | +| `PORT` | string | `3000` | HTTP Port | +| `APP_ENV` | string | `production` | Automatisch Environment | +| `WEB_APP_URL` | string | | Can be used to override connection URLs and CORS URL | +| `WEBHOOK_URL` | string | | Can be used to override webhook URL | +| `LOG_LEVEL` | string | `info` | Can be used to configure log level such as `error`, `warn`, `info`, `http`, `debug` | +| `POSTGRES_DATABASE` | string | `automatisch` | Database Name | +| `POSTGRES_SCHEMA` | string | `public` | Database Schema | +| `POSTGRES_PORT` | number | `5432` | Database Port | +| `POSTGRES_ENABLE_SSL` | boolean | `false` | Enable/Disable SSL for the database | +| `POSTGRES_HOST` | string | `postgres` | Database Host | +| `POSTGRES_USERNAME` | string | `automatisch_user` | Database User | +| `POSTGRES_PASSWORD` | string | | Password of Database User | +| `ENCRYPTION_KEY` | string | | Encryption Key to store credentials | +| `WEBHOOK_SECRET_KEY` | string | | Webhook Secret Key to verify webhook requests | +| `APP_SECRET_KEY` | string | | Secret Key to authenticate the user | +| `REDIS_HOST` | string | `redis` | Redis Host | +| `REDIS_PORT` | number | `6379` | Redis Port | +| `REDIS_DB` | number | | Redis Database | +| `REDIS_USERNAME` | string | | Redis Username | +| `REDIS_PASSWORD` | string | | Redis Password | +| `REDIS_TLS` | boolean | `false` | Redis TLS | +| `TELEMETRY_ENABLED` | boolean | `true` | Enable/Disable Telemetry | +| `ENABLE_BULLMQ_DASHBOARD` | boolean | `false` | Enable BullMQ Dashboard | +| `BULLMQ_DASHBOARD_USERNAME` | string | | Username to login BullMQ Dashboard | +| `BULLMQ_DASHBOARD_PASSWORD` | string | | Password to login BullMQ Dashboard | +| `DISABLE_NOTIFICATIONS_PAGE` | boolean | `false` | Enable/Disable notifications page | +| `DISABLE_FAVICON` | boolean | `false` | Enable/Disable favicon | diff --git a/packages/docs/pages/advanced/credentials.md b/packages/docs/pages/advanced/credentials.md new file mode 100644 index 0000000..e30a88a --- /dev/null +++ b/packages/docs/pages/advanced/credentials.md @@ -0,0 +1,9 @@ +# Credentials + +We need to store your credentials in order to automatically communicate with third-party services to fetch and send data when you have connections. It's the nature of our software and how automation works, but we take extra measures to keep your third-party credentials safe and secure. + +Automatisch uses AES specification to encrypt and decrypt your credentials of third-party services. The Advanced Encryption Standard (AES) is a U.S. Federal Information Processing Standard (FIPS). It was selected after a 5-year process where 15 competing designs were evaluated. AES is now used worldwide to protect sensitive information. + +:::danger +Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment variables. They are used to encrypt your credentials from third-party services and verify webhook requests. If you change them, your existing connections and flows will not continue to work. +::: diff --git a/packages/docs/pages/advanced/telemetry.md b/packages/docs/pages/advanced/telemetry.md new file mode 100644 index 0000000..d1bdb8e --- /dev/null +++ b/packages/docs/pages/advanced/telemetry.md @@ -0,0 +1,33 @@ +# Telemetry + +:::info +We want to be very transparent about the data we collect and how we use it. Therefore, we have abstracted all of the code we use with our telemetry system into a single, easily accessible place. You can check the code [here](https://github.com/automatisch/automatisch/blob/main/packages/backend/src/helpers/telemetry/index.js) and let us know if you have any suggestions for changes. +::: + +Automatisch comes with a built-in telemetry system that collects anonymous usage data. This data is used to help us improve the product and to make sure we are focusing on the right features. While we're doing it, we don't collect any personal information. You can also disable the telemetry system by setting the `TELEMETRY_ENABLED` environment variable. See the [environment variables](/advanced/configuration#environment-variables) section for more information. + +## What Automatisch collects? + +- Flow, step, and connection data without any credentials. +- Execution and execution steps data without any payload or identifiable information. +- Organization and instance IDs. Those are random IDs we assign when you install Automatisch. They're helpful when we evaluate how many instances are running and how many organizations are using Automatisch. +- Diagnostic information + - Automatisch version + - Service type (main service or worker service) + - Operating system type and version + - CPU and memory information + +## What Automatisch do not collect? + +- Personal information +- Your credentials of third party services +- Email and password used with Automatisch +- Error payloads + +## How to disable telemetry? + +Telemetry is enabled by default. If you want to disable it, you can do so by setting the `TELEMETRY_ENABLED` environment variable to `false` in `docker-compose.yml` file. + +## How data collection works? + +Automatisch collects data with events associated with custom user actions. We send the data to our servers whenever the user triggers those custom actions. Apart from events that are triggered by user actions, we also collect diagnostic information every six hours. diff --git a/packages/docs/pages/apps/airtable/actions.md b/packages/docs/pages/apps/airtable/actions.md new file mode 100644 index 0000000..432e0d4 --- /dev/null +++ b/packages/docs/pages/apps/airtable/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/airtable.svg +items: + - name: Create record + desc: Creates a new record with fields that automatically populate. + - name: Find record + desc: Finds a record using simple field search or use Airtable's formula syntax to find a matching record. +--- + + + + diff --git a/packages/docs/pages/apps/airtable/connection.md b/packages/docs/pages/apps/airtable/connection.md new file mode 100644 index 0000000..57bcd78 --- /dev/null +++ b/packages/docs/pages/apps/airtable/connection.md @@ -0,0 +1,19 @@ +# Airtable + +:::info +This page explains the steps you need to follow to set up the Airtable +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your [Airtable account](https://www.airtable.com/). +2. Go to this [link](https://airtable.com/create/oauth) and click on the **Register new OAuth integration**. +3. Fill the name field. +4. Copy **OAuth Redirect URL** from Automatisch to **OAuth redirect URL** field. +5. Click on the **Register integration** button. +6. In **Developer Details** section, click on the **Generate client secret**. +7. Check the checkboxes of **Scopes** section. +8. Click on the **Save changes** button. +9. Copy **Client ID** to **Client ID** field on Automatisch. +10. Copy **Client secret** to **Client secret** field on Automatisch. +11. Click **Submit** button on Automatisch. +12. Congrats! Start using your new Airtable connection within the flows. diff --git a/packages/docs/pages/apps/anthropic/actions.md b/packages/docs/pages/apps/anthropic/actions.md new file mode 100644 index 0000000..6e8d4cf --- /dev/null +++ b/packages/docs/pages/apps/anthropic/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/anthropic.svg +items: + - name: Send message + desc: Sends a structured list of input messages with text content, and the model will generate the next message in the conversation. +--- + + + + diff --git a/packages/docs/pages/apps/anthropic/connection.md b/packages/docs/pages/apps/anthropic/connection.md new file mode 100644 index 0000000..92330b8 --- /dev/null +++ b/packages/docs/pages/apps/anthropic/connection.md @@ -0,0 +1,8 @@ +# Anthropic + +1. Go to [API Keys page](https://console.anthropic.com/settings/keys) on Anthropic. +2. Create a new key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Anthropic integration with Automatisch! diff --git a/packages/docs/pages/apps/appwrite/connection.md b/packages/docs/pages/apps/appwrite/connection.md new file mode 100644 index 0000000..d013b8c --- /dev/null +++ b/packages/docs/pages/apps/appwrite/connection.md @@ -0,0 +1,20 @@ +# Appwrite + +:::info +This page explains the steps you need to follow to set up the Appwrite +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your Appwrite account: [https://appwrite.io/](https://appwrite.io/). +2. Go to your project's **Settings**. +3. In the Settings, click on the **View API Keys** button in **API credentials** section. +4. Click on the **Create API Key** button. +5. Fill the name field and select **Never** for the expiration date. +6. Click on the **Next** button. +7. Click on the **Select all** and then click on the **Create** button. +8. Now, copy your **API key secret** and paste the key into the **API Key** field in Automatisch. +9. Write any screen name to be displayed in Automatisch. +10. You can find your project ID next to your project name. Paste the id into **Project ID** field in Automatisch. +11. If you are using self-hosted Appwrite project, you can paste the instance url into **Appwrite instance URL** field in Automatisch. +12. Fill the host name field with the hostname of your instance URL. It's either `cloud.appwrite.io` or hostname of your instance URL. +13. Start using Appwrite integration with Automatisch! diff --git a/packages/docs/pages/apps/appwrite/triggers.md b/packages/docs/pages/apps/appwrite/triggers.md new file mode 100644 index 0000000..b7e282f --- /dev/null +++ b/packages/docs/pages/apps/appwrite/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/appwrite.svg +items: + - name: New documents + desc: Triggers when a new document is created. +--- + + + + diff --git a/packages/docs/pages/apps/brave-search/actions.md b/packages/docs/pages/apps/brave-search/actions.md new file mode 100644 index 0000000..9c9da7b --- /dev/null +++ b/packages/docs/pages/apps/brave-search/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/brave-search.svg +items: + - name: Web search + desc: Queries Brave Search and get back search results from the web. +--- + + + + diff --git a/packages/docs/pages/apps/brave-search/connection.md b/packages/docs/pages/apps/brave-search/connection.md new file mode 100644 index 0000000..eadab30 --- /dev/null +++ b/packages/docs/pages/apps/brave-search/connection.md @@ -0,0 +1,8 @@ +# Brave Search + +1. Go to [API Keys page](https://api.search.brave.com/app/keys) on Brave Search. +2. Create a new API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Brave Search integration with Automatisch! diff --git a/packages/docs/pages/apps/carbone/actions.md b/packages/docs/pages/apps/carbone/actions.md new file mode 100644 index 0000000..f67b5ae --- /dev/null +++ b/packages/docs/pages/apps/carbone/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/carbone.svg +items: + - name: Add Template + desc: Adds a template in xml/html format to your Carbone account. +--- + + + + diff --git a/packages/docs/pages/apps/carbone/connection.md b/packages/docs/pages/apps/carbone/connection.md new file mode 100644 index 0000000..598e4df --- /dev/null +++ b/packages/docs/pages/apps/carbone/connection.md @@ -0,0 +1,10 @@ +# Carbone + +:::info +This page explains the steps you need to follow to set up the Carbone +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your Carbone account: [https://account.carbone.io/](https://account.carbone.io/). +2. Copy either `Test API key` or `Production API key` from the page to the `API Key` field on Automatisch. +3. Now, you can start using the Carbone connection with Automatisch. diff --git a/packages/docs/pages/apps/clickup/actions.md b/packages/docs/pages/apps/clickup/actions.md new file mode 100644 index 0000000..7a7abb3 --- /dev/null +++ b/packages/docs/pages/apps/clickup/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/clickup.svg +items: + - name: Create folder + desc: Creates a new folder. + - name: Create list + desc: Creates a new list. + - name: Create task + desc: Creates a new task. + - name: Find task by id + desc: Finds a task using id. +--- + + + + diff --git a/packages/docs/pages/apps/clickup/connection.md b/packages/docs/pages/apps/clickup/connection.md new file mode 100644 index 0000000..d1e6702 --- /dev/null +++ b/packages/docs/pages/apps/clickup/connection.md @@ -0,0 +1,17 @@ +# ClickUp + +:::info +This page explains the steps you need to follow to set up the ClickUp +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your ClickUp account: [https://app.clickup.com/login](https://app.clickup.com/login). +2. Go to **Settings** page. +3. Click on the **ClickUp API** tab on the left. +4. Click on the **Create an App** button. +5. Fill the name field. +6. Copy **OAuth Redirect URL** from Automatisch to **Redirect URL(s)** field. +7. Copy **Client ID** to **Client ID** field on Automatisch. +8. Copy **Client Secret** to **Client Secret** field on Automatisch. +9. Click **Submit** button on Automatisch. +10. Congrats! Start using your new ClickUp connection within the flows. diff --git a/packages/docs/pages/apps/clickup/triggers.md b/packages/docs/pages/apps/clickup/triggers.md new file mode 100644 index 0000000..0ab70fe --- /dev/null +++ b/packages/docs/pages/apps/clickup/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/clickup.svg +items: + - name: New folders + desc: Triggers when a new folder is created. + - name: New lists + desc: Triggers when a new list is created. + - name: New tasks + desc: Triggers when a new task is created. + - name: Updated task + desc: Triggers when a task is updated. +--- + + + + diff --git a/packages/docs/pages/apps/cryptography/actions.md b/packages/docs/pages/apps/cryptography/actions.md new file mode 100644 index 0000000..a7aa2a7 --- /dev/null +++ b/packages/docs/pages/apps/cryptography/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/cryptography.svg +items: + - name: Create HMAC + desc: Create a Hash-based Message Authentication Code (HMAC) using the specified algorithm, secret key, and message. + - name: Create Signature + desc: Create a digital signature using the specified algorithm, secret key, and message. +--- + + + + diff --git a/packages/docs/pages/apps/cryptography/connection.md b/packages/docs/pages/apps/cryptography/connection.md new file mode 100644 index 0000000..5cf2856 --- /dev/null +++ b/packages/docs/pages/apps/cryptography/connection.md @@ -0,0 +1,3 @@ +# Cryptography + +Cryptography is a built-in app shipped with Automatisch, allowing you to perform cryptographic operations without needing to connect to any external services. diff --git a/packages/docs/pages/apps/datastore/actions.md b/packages/docs/pages/apps/datastore/actions.md new file mode 100644 index 0000000..d1c3c36 --- /dev/null +++ b/packages/docs/pages/apps/datastore/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/datastore.svg +items: + - name: Get value + desc: Get value from the persistent datastore. + - name: Set value + desc: Set value to the persistent datastore. +--- + + + + diff --git a/packages/docs/pages/apps/datastore/connection.md b/packages/docs/pages/apps/datastore/connection.md new file mode 100644 index 0000000..f8a593f --- /dev/null +++ b/packages/docs/pages/apps/datastore/connection.md @@ -0,0 +1,3 @@ +# Datastore + +Datastore is a persistent key-value storage system that allows you to store and retrieve data. Currently you can use it within the scope of the flow, meaning you can store and retrieve data within the same flow. diff --git a/packages/docs/pages/apps/deepl/actions.md b/packages/docs/pages/apps/deepl/actions.md new file mode 100644 index 0000000..3b042d6 --- /dev/null +++ b/packages/docs/pages/apps/deepl/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/deepl.svg +items: + - name: Translate text + desc: Translates text from one language to another. +--- + + + + diff --git a/packages/docs/pages/apps/deepl/connection.md b/packages/docs/pages/apps/deepl/connection.md new file mode 100644 index 0000000..e644fe5 --- /dev/null +++ b/packages/docs/pages/apps/deepl/connection.md @@ -0,0 +1,8 @@ +# DeepL + +1. Go to [your account page](https://www.deepl.com/account/summary) on DeepL. +2. Scroll down and copy `Authentication Key for DeepL API`. +3. Paste the key into the `Authentication Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using DeepL integration with Automatisch! diff --git a/packages/docs/pages/apps/delay/actions.md b/packages/docs/pages/apps/delay/actions.md new file mode 100644 index 0000000..6254b08 --- /dev/null +++ b/packages/docs/pages/apps/delay/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/delay.svg +items: + - name: Delay for + desc: Delays the execution of the next action by a specified amount of time. + - name: Delay until + desc: Delays the execution of the next action until a specified date. +--- + + + + diff --git a/packages/docs/pages/apps/delay/connection.md b/packages/docs/pages/apps/delay/connection.md new file mode 100644 index 0000000..a2eec35 --- /dev/null +++ b/packages/docs/pages/apps/delay/connection.md @@ -0,0 +1,3 @@ +# Delay + +Delay is a built-in app shipped with Automatisch, and it doesn't need to talk with any other external service to run. So there are no additional steps to use the Delay app. It can be used only as an action and it delays the execution of the next action by a specified amount of time. diff --git a/packages/docs/pages/apps/discord/actions.md b/packages/docs/pages/apps/discord/actions.md new file mode 100644 index 0000000..111a90e --- /dev/null +++ b/packages/docs/pages/apps/discord/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/discord.svg +items: + - name: Send a message to channel + desc: Sends a message to a specific channel you specify. + - name: Create a scheduled event + desc: Creates a scheduled event. +--- + + + + diff --git a/packages/docs/pages/apps/discord/connection.md b/packages/docs/pages/apps/discord/connection.md new file mode 100644 index 0000000..10e9ee8 --- /dev/null +++ b/packages/docs/pages/apps/discord/connection.md @@ -0,0 +1,26 @@ +# Discord + +:::info +This page explains the steps you need to follow to set up the Discord +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://discord.com/developers/applications) to register a **new application** on Discord. +1. Fill **Name**. +1. Check the checkboxes. +1. Click on the **create** button. +1. Go to **OAuth2** > **General** page. +1. Copy the **Client ID** and save it to use later. +1. Reset the **Client secret** to get the initial client secret and copy it to use later. +1. Click the **Add Redirect** button to define a redirect URI. +1. Copy **OAuth Redirect URL** from Automatisch to **Redirect** field. +1. Save the changes. +1. Go to **Bot** page. +1. Click **Add Bot** button. +1. Acknowledge the warning and click **Yes, do it!**. +1. Click **Reset Token** to get the initial bot token and copy it to use later. +1. Fill the **Consumer key** field with the **Client ID** value we copied. +1. Fill the **Consumer secret** field with the **Client Secret** value we copied. +1. Fill the **Bot token** field with the **Bot Token** value we copied. +1. Click **Submit** button on Automatisch. +1. Congrats! Start using your new Discord connection within the flows. diff --git a/packages/docs/pages/apps/disqus/connection.md b/packages/docs/pages/apps/disqus/connection.md new file mode 100644 index 0000000..b03539c --- /dev/null +++ b/packages/docs/pages/apps/disqus/connection.md @@ -0,0 +1,19 @@ +# Disqus + +:::info +This page explains the steps you need to follow to set up the Disqus +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to the [Disqus](https://disqus.com/). +2. Go to the [API applications page](https://disqus.com/api/applications/) and click on the **Register new application** button. +3. Fill the **Register Application** form fields. +4. Click on the **Register my application** button. +5. Go to the **Authentication** section and select **Read, Write, and Manage Forums** option. +6. Copy **OAuth Redirect URL** from Automatisch to **Callback URL** field. +7. Click on the **Save Changes** button. +8. Go to the **Details** tab. +9. Copy **API Key** to **API Key** field on Automatisch. +10. Copy **API Secret** to **API Secret** field on Automatisch. +11. Click **Submit** button on Automatisch. +12. Congrats! Start using your new Disqus connection within the flows. diff --git a/packages/docs/pages/apps/disqus/triggers.md b/packages/docs/pages/apps/disqus/triggers.md new file mode 100644 index 0000000..a40273a --- /dev/null +++ b/packages/docs/pages/apps/disqus/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/disqus.svg +items: + - name: New comments + desc: Triggers when a new comment is posted in a forum using Disqus. + - name: New flagged comments + desc: Triggers when a Disqus comment is marked with a flag. +--- + + + + diff --git a/packages/docs/pages/apps/dropbox/actions.md b/packages/docs/pages/apps/dropbox/actions.md new file mode 100644 index 0000000..4d5d4b3 --- /dev/null +++ b/packages/docs/pages/apps/dropbox/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/dropbox.svg +items: + - name: Create a folder + desc: Creates a new folder with the given parent folder and folder name. + - name: Rename a file + desc: Rename a file with the given file path and new name. +--- + + + + diff --git a/packages/docs/pages/apps/dropbox/connection.md b/packages/docs/pages/apps/dropbox/connection.md new file mode 100644 index 0000000..8869614 --- /dev/null +++ b/packages/docs/pages/apps/dropbox/connection.md @@ -0,0 +1,20 @@ +# Dropbox + +:::info +This page explains the steps you need to follow to set up the Dropbox +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.dropbox.com/developers/apps) to create a **new application** on Dropbox. +1. Choose the "Scoped access" option in the "Choose an API" section. +1. Choose the "Full Dropbox" option in the "Choose the type of access you need" section. +1. Name your application. +1. Click on the **Create app** button. +1. Copy **OAuth Redirect URL** from Automatisch to **Redirect URIs** field and add it. +1. Click on the **Scoped App** link in the "Permission type" section. +1. Check the checkbox for the "files.content.write" scope and click on the **Submit** button. +1. Go back to the "Settings" tab. +1. Copy **App key** to **App key** field on Automatisch. +1. Copy **App secret** to **App secret** field on Automatisch. +1. Click **Submit** button on Automatisch. +1. Congrats! Start using your new Dropbox connection within the flows. diff --git a/packages/docs/pages/apps/filter/actions.md b/packages/docs/pages/apps/filter/actions.md new file mode 100644 index 0000000..319e976 --- /dev/null +++ b/packages/docs/pages/apps/filter/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/filter.svg +items: + - name: Continue if conditions match + desc: Let the execution continue if the conditions match. +--- + + + + diff --git a/packages/docs/pages/apps/filter/connection.md b/packages/docs/pages/apps/filter/connection.md new file mode 100644 index 0000000..742b066 --- /dev/null +++ b/packages/docs/pages/apps/filter/connection.md @@ -0,0 +1,12 @@ +# Filter + +Filter is a built-in app shipped with Automatisch, and it doesn't need to talk with any other external service to run. So there are no additional steps to use the Filter app. It can be used as an action and it filters the flow based on the given conditions. Available conditions are: + +- is equal +- is not equal +- is greater than +- is less than +- is greater than or equal +- is less than or equal +- contains +- does not contain diff --git a/packages/docs/pages/apps/flickr/connection.md b/packages/docs/pages/apps/flickr/connection.md new file mode 100644 index 0000000..3558e94 --- /dev/null +++ b/packages/docs/pages/apps/flickr/connection.md @@ -0,0 +1,22 @@ +# Flickr + +:::info +This page explains the steps you need to follow to set up the Flickr +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.flickr.com/services/apps/create/) to **create an + app** on Flickr API. +2. Click **Request an API key**. +3. Apply for a non-commercial key. +4. Fill the field of **What is the name of your app?**. +5. Fill the field of **What are you building?**. +6. Check the checkboxes. +7. Click on **Submit** button. +8. Copy **Key** and **Key Secret** values and save them to use later. +9. Click **Edit auth flow for this app** to configure "Callback URL". +10. Copy **OAuth Redirect URL** from Automatisch and paste it to the **Callback URL** field. +11. Click **Save changes**. +12. Paste **Key** and **Secret** values you have saved from the 8th step and paste them into Automatisch as **Consumer Key** and **Consumer Secret**, respectively. +13. Click **Submit** button on Automatisch. +14. Now, you can start using the Flickr connection with Automatisch. diff --git a/packages/docs/pages/apps/flickr/triggers.md b/packages/docs/pages/apps/flickr/triggers.md new file mode 100644 index 0000000..6067900 --- /dev/null +++ b/packages/docs/pages/apps/flickr/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/flickr.svg +items: + - name: New albums + desc: Triggers when you create a new album. + - name: New favorite photos + desc: Triggers when you favorite a photo. + - name: New photos + desc: Triggers when you add a new photo. + - name: New photos in album + desc: Triggers when you add a new photo in an album. +--- + + + + diff --git a/packages/docs/pages/apps/formatter/actions.md b/packages/docs/pages/apps/formatter/actions.md new file mode 100644 index 0000000..acceaa4 --- /dev/null +++ b/packages/docs/pages/apps/formatter/actions.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/formatter.svg +items: + - name: Text + desc: Transform text data to capitalize, extract emails, apply default value, and much more. + - name: Numbers + desc: Transform numbers to perform math operations, generate random numbers, format numbers, and much more. + - name: Date / Time + desc: Perform date and time related transformations on your data. +--- + + + + diff --git a/packages/docs/pages/apps/formatter/connection.md b/packages/docs/pages/apps/formatter/connection.md new file mode 100644 index 0000000..772de8b --- /dev/null +++ b/packages/docs/pages/apps/formatter/connection.md @@ -0,0 +1,26 @@ +# Formatter + +Formatter is a built-in app shipped with Automatisch, and it doesn't need to talk with any other external service to run. So there are no additional steps to use the Formatter app. It can be used as an action, and you can use it to format the data from the previous steps. It can be used to format the data in the following ways. + +## Text + +- Capitalize +- Convert HTML to Markdown +- Convert Markdown to HTML +- Extract Email Address +- Extract Number +- Lowercase +- Pluralize +- Replace +- Trim Whitespace +- Use Default Value + +## Numbers + +- Perform Math Operation +- Random Number +- Format Number + +## Date / Time + +- Format Date / Time diff --git a/packages/docs/pages/apps/freescout/connection.md b/packages/docs/pages/apps/freescout/connection.md new file mode 100644 index 0000000..b0ae886 --- /dev/null +++ b/packages/docs/pages/apps/freescout/connection.md @@ -0,0 +1,13 @@ +# FreeScout + +:::info +This page explains the steps you need to follow to set up the FreeScout +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to your FreeScout instance. +2. Go to the Manage > Settings > API & Webhooks page. +3. Generate **API Key**. +4. Copy the **API Key** value to the `API Key` field on Automatisch. +5. Click **Submit** button on Automatisch. +6. Congrats! Start using your new FreeScout connection within the flows. diff --git a/packages/docs/pages/apps/freescout/triggers.md b/packages/docs/pages/apps/freescout/triggers.md new file mode 100644 index 0000000..9bdeda5 --- /dev/null +++ b/packages/docs/pages/apps/freescout/triggers.md @@ -0,0 +1,13 @@ +--- +favicon: /favicons/freescout.svg +items: + - name: New event + desc: Triggers when a new event is created. The supported events are conversation created, conversation assigned, conversation status updated, conversation moved, conversation deleted, conversation deleted forever, conversation restored from deleted folder, customer replied, agent replied, note added, customer created, customer updated. + +--- + + + + diff --git a/packages/docs/pages/apps/ghost/connection.md b/packages/docs/pages/apps/ghost/connection.md new file mode 100644 index 0000000..2de3528 --- /dev/null +++ b/packages/docs/pages/apps/ghost/connection.md @@ -0,0 +1,13 @@ +# Ghost + +:::info +This page explains the steps you need to follow to set up the Ghost connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to your Ghost Admin panel. +2. Click on the **Integrations** button. +3. Click on the **Add custom integration** button and create Admin API key. +4. Add your Admin API Key in the **Admin API Key** field on Automatisch. +5. Add your API URL in the **Instance URL** field on Automatisch. +6. Click **Submit** button on Automatisch. +7. Congrats! Start using your new Ghost connection within the flows. diff --git a/packages/docs/pages/apps/ghost/triggers.md b/packages/docs/pages/apps/ghost/triggers.md new file mode 100644 index 0000000..dbb88b3 --- /dev/null +++ b/packages/docs/pages/apps/ghost/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/ghost.svg +items: + - name: New post published + desc: Triggers when a new post is published. +--- + + + + diff --git a/packages/docs/pages/apps/github/actions.md b/packages/docs/pages/apps/github/actions.md new file mode 100644 index 0000000..b5ae61b --- /dev/null +++ b/packages/docs/pages/apps/github/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/github.svg +items: + - name: Create issue + desc: Creates a new issue. +--- + + + + diff --git a/packages/docs/pages/apps/github/connection.md b/packages/docs/pages/apps/github/connection.md new file mode 100644 index 0000000..28acfc1 --- /dev/null +++ b/packages/docs/pages/apps/github/connection.md @@ -0,0 +1,15 @@ +# Github + +:::info +This page explains the steps you need to follow to set up the Github +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://github.com/settings/applications/new) to register a **new OAuth application** on Github. +2. Fill **Application name** and **Homepage URL**. +3. Copy **OAuth Redirect URL** from Automatisch to **Authorization callback URL** field on Github page. +4. Click on the **Register application** button on the Github page. +5. Copy the **Client ID** value from the following page to the `Client ID` field on Automatisch. +6. Click **Generate a new client secret** on the Github page and copy generated value into the `Client Secret` field on Automatisch. +7. Click **Submit** button on Automatisch. +8. Congrats! Start using your new Github connection within the flows. diff --git a/packages/docs/pages/apps/github/triggers.md b/packages/docs/pages/apps/github/triggers.md new file mode 100644 index 0000000..ec82d3b --- /dev/null +++ b/packages/docs/pages/apps/github/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/github.svg +items: + - name: New issues + desc: Triggers when a new issue is created. + - name: New pull requests + desc: Triggers when a new pull request is created. + - name: New stargazers + desc: Triggers when a user stars a repository. + - name: New watchers + desc: Triggers when a user watches a repository. +--- + + + + diff --git a/packages/docs/pages/apps/gitlab/connection.md b/packages/docs/pages/apps/gitlab/connection.md new file mode 100644 index 0000000..d452302 --- /dev/null +++ b/packages/docs/pages/apps/gitlab/connection.md @@ -0,0 +1,18 @@ +# Gitlab + +:::info +This page explains the steps you need to follow to set up the Gitlab +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://gitlab.com/-/profile/applications) to register a **new OAuth application** on Gitlab. +2. Fill application **Name**. +3. Copy **OAuth Redirect URL** from Automatisch to **Redirect URI** field on Gitlab page. +4. Mark the **Confidential** field on Gitlab page. +5. Mark the **api** and **read_user** in **Scopes** section on Gitlab page. +6. Click on the **Save application** button at the end of the form on Gitlab page. +7. Copy the **Application ID** value from the following page to the `Client ID` field on Automatisch. +8. Copy the **Secret** value from the same page to the `Client Secret` field on Automatisch. +9. Click **Continue** button on Gitlab page. +10. Click **Submit** button on Automatisch. +11. Congrats! Start using your new Github connection within the flows. diff --git a/packages/docs/pages/apps/gitlab/triggers.md b/packages/docs/pages/apps/gitlab/triggers.md new file mode 100644 index 0000000..b2a2efb --- /dev/null +++ b/packages/docs/pages/apps/gitlab/triggers.md @@ -0,0 +1,36 @@ +--- +favicon: /favicons/gitlab.svg +items: + - name: Confidential issue event + desc: Triggers when a new confidential issue is created or an existing issue is updated, closed, or reopened. + - name: Confidential comment event + desc: Triggers when a new confidential comment is made on commits, merge requests, issues, and code snippets. + - name: Deployment event + desc: Triggers when a deployment starts, succeeds, fails or is canceled. + - name: Feature flag event + desc: Triggers when a feature flag is turned on or off. + - name: Issue event + desc: Triggers when a new issue is created or an existing issue is updated, closed, or reopened. + - name: Job event + desc: Triggers when the status of a job changes. + - name: Merge request event + desc: Triggers when merge request is created, updated, or closed. + - name: Comment event + desc: Triggers when a new comment is made on commits, merge requests, issues, and code snippets. + - name: Pipeline event + desc: Triggers when the status of a pipeline changes. + - name: Push event + desc: Triggers when you push to the repository. + - name: Release event + desc: Triggers when a release is created or updated. + - name: Tag event + desc: Triggers when you create or delete tags in the repository. + - name: Wiki page event + desc: Triggers when a wiki page is created, updated, or deleted. +--- + + + + diff --git a/packages/docs/pages/apps/google-calendar/connection.md b/packages/docs/pages/apps/google-calendar/connection.md new file mode 100644 index 0000000..6024e6d --- /dev/null +++ b/packages/docs/pages/apps/google-calendar/connection.md @@ -0,0 +1,28 @@ +# Google Calendar + +:::info +This page explains the steps you need to follow to set up the Google Calendar +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project. +2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button. +3. Enter a name for your project and click on the **Create** button. +4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console. +5. Search for **Google Calendar API** in the search bar and click on it. +6. Click on the **Enable** button to enable the API. +7. Repeat steps 5 and 6 for the **People API**. +8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console. +9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button. +10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button. +11. Skip adding or removing scopes and click on the **Save and Continue** button. +12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing". +13. Click on the **Save and Continue** button and now you have configured the consent screen. +14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console. +15. Click on the **Create Credentials** button and select the **OAuth client ID** option. +16. Select the application type as **Web application** and fill the **Name** field. +17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button. +18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch. +19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch. +20. Click **Submit** button on Automatisch. +21. Congrats! Start using your new Google Calendar connection within the flows. diff --git a/packages/docs/pages/apps/google-calendar/triggers.md b/packages/docs/pages/apps/google-calendar/triggers.md new file mode 100644 index 0000000..4dc21a8 --- /dev/null +++ b/packages/docs/pages/apps/google-calendar/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/google-calendar.svg +items: + - name: New calendar + desc: Triggers when a new calendar is created. + - name: New event + desc: Triggers when a new event is created. +--- + + + + diff --git a/packages/docs/pages/apps/google-drive/connection.md b/packages/docs/pages/apps/google-drive/connection.md new file mode 100644 index 0000000..ee783ba --- /dev/null +++ b/packages/docs/pages/apps/google-drive/connection.md @@ -0,0 +1,28 @@ +# Google Drive + +:::info +This page explains the steps you need to follow to set up the Google Drive +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project. +2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button. +3. Enter a name for your project and click on the **Create** button. +4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console. +5. Search for **People API** in the search bar and click on it. +6. Click on the **Enable** button to enable the API. +7. Repeat steps 5 and 6 for the **Google Drive API** +8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console. +9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button. +10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button. +11. Skip adding or removing scopes and click on the **Save and Continue** button. +12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing". +13. Click on the **Save and Continue** button and now you have configured the consent screen. +14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console. +15. Click on the **Create Credentials** button and select the **OAuth client ID** option. +16. Select the application type as **Web application** and fill the **Name** field. +17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button. +18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch. +19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch. +20. Click **Submit** button on Automatisch. +21. Congrats! Start using your new Google Drive connection within the flows. diff --git a/packages/docs/pages/apps/google-drive/triggers.md b/packages/docs/pages/apps/google-drive/triggers.md new file mode 100644 index 0000000..b7a6704 --- /dev/null +++ b/packages/docs/pages/apps/google-drive/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/google-drive.svg +items: + - name: New files + desc: Triggers when any new file is added (inside of any folder). + - name: New files in folder + desc: Triggers when a new file is added directly to a specified folder (but not its subfolder). + - name: New folders + desc: Triggers when a new folder is added directly to a specified folder (but not its subfolder). + - name: Updated files + desc: Triggers when a file is updated in a specified folder (but not its subfolder). +--- + + + + diff --git a/packages/docs/pages/apps/google-forms/connection.md b/packages/docs/pages/apps/google-forms/connection.md new file mode 100644 index 0000000..b782b44 --- /dev/null +++ b/packages/docs/pages/apps/google-forms/connection.md @@ -0,0 +1,28 @@ +# Google Forms + +:::info +This page explains the steps you need to follow to set up the Google Forms +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project. +2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button. +3. Enter a name for your project and click on the **Create** button. +4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console. +5. Search for **People API** in the search bar and click on it. +6. Click on the **Enable** button to enable the API. +7. Repeat steps 5 and 6 for the **Google Forms API** +8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console. +9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button. +10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button. +11. Skip adding or removing scopes and click on the **Save and Continue** button. +12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing". +13. Click on the **Save and Continue** button and now you have configured the consent screen. +14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console. +15. Click on the **Create Credentials** button and select the **OAuth client ID** option. +16. Select the application type as **Web application** and fill the **Name** field. +17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button. +18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch. +19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch. +20. Click **Submit** button on Automatisch. +21. Congrats! Start using your new Google Forms connection within the flows. diff --git a/packages/docs/pages/apps/google-forms/triggers.md b/packages/docs/pages/apps/google-forms/triggers.md new file mode 100644 index 0000000..15e1e58 --- /dev/null +++ b/packages/docs/pages/apps/google-forms/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/google-forms.svg +items: + - name: New form responses + desc: Triggers when a new form response is submitted. +--- + + + + diff --git a/packages/docs/pages/apps/google-sheets/actions.md b/packages/docs/pages/apps/google-sheets/actions.md new file mode 100644 index 0000000..caf6137 --- /dev/null +++ b/packages/docs/pages/apps/google-sheets/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/google-sheets.svg +items: + - name: Create spreadsheet + desc: Create a blank spreadsheet or duplicate an existing spreadsheet. Optionally, provide headers. + - name: Create spreadsheet row + desc: Creates a new row in a specific spreadsheet. + - name: Create worksheet + desc: Create a blank worksheet with a title. Optionally, provide headers. + - name: Find worksheet + desc: Finds a worksheet by title. Optionally, create a worksheet if none are found. +--- + + + + diff --git a/packages/docs/pages/apps/google-sheets/connection.md b/packages/docs/pages/apps/google-sheets/connection.md new file mode 100644 index 0000000..79afb68 --- /dev/null +++ b/packages/docs/pages/apps/google-sheets/connection.md @@ -0,0 +1,28 @@ +# Google Sheets + +:::info +This page explains the steps you need to follow to set up the Google Sheets +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project. +2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button. +3. Enter a name for your project and click on the **Create** button. +4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console. +5. Search for **People API** in the search bar and click on it. +6. Click on the **Enable** button to enable the API. +7. Repeat steps 5 and 6 for the **Google Drive API** and **Google Sheets API**. +8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console. +9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button. +10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button. +11. Skip adding or removing scopes and click on the **Save and Continue** button. +12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing". +13. Click on the **Save and Continue** button and now you have configured the consent screen. +14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console. +15. Click on the **Create Credentials** button and select the **OAuth client ID** option. +16. Select the application type as **Web application** and fill the **Name** field. +17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button. +18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch. +19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch. +20. Click **Submit** button on Automatisch. +21. Congrats! Start using your new Google Sheets connection within the flows. diff --git a/packages/docs/pages/apps/google-sheets/triggers.md b/packages/docs/pages/apps/google-sheets/triggers.md new file mode 100644 index 0000000..88f2710 --- /dev/null +++ b/packages/docs/pages/apps/google-sheets/triggers.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/google-sheets.svg +items: + - name: New spreadsheets + desc: Triggers when you create a new spreadsheet. + - name: New worksheets + desc: Triggers when you create a new worksheet in a spreadsheet. + - name: New spreadsheet rows + desc: Triggers when a new row is added to the bottom of a spreadsheet. +--- + + + + diff --git a/packages/docs/pages/apps/google-tasks/actions.md b/packages/docs/pages/apps/google-tasks/actions.md new file mode 100644 index 0000000..2ed2950 --- /dev/null +++ b/packages/docs/pages/apps/google-tasks/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/google-tasks.svg +items: + - name: Create task + desc: Creates a new task. + - name: Create task list + desc: Creates a new task list. + - name: Find task + desc: Looking for a specific task. + - name: Update task + desc: Updates an existing task. +--- + + + + diff --git a/packages/docs/pages/apps/google-tasks/connection.md b/packages/docs/pages/apps/google-tasks/connection.md new file mode 100644 index 0000000..c09c62d --- /dev/null +++ b/packages/docs/pages/apps/google-tasks/connection.md @@ -0,0 +1,28 @@ +# Google Tasks + +:::info +This page explains the steps you need to follow to set up the Google Tasks +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project. +2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button. +3. Enter a name for your project and click on the **Create** button. +4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console. +5. Search for **Google Tasks API** in the search bar and click on it. +6. Click on the **Enable** button to enable the API. +7. Repeat steps 5 and 6 for the **People API**. +8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console. +9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button. +10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button. +11. Skip adding or removing scopes and click on the **Save and Continue** button. +12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing". +13. Click on the **Save and Continue** button and now you have configured the consent screen. +14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console. +15. Click on the **Create Credentials** button and select the **OAuth client ID** option. +16. Select the application type as **Web application** and fill the **Name** field. +17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button. +18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch. +19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch. +20. Click **Submit** button on Automatisch. +21. Congrats! Start using your new Google Tasks connection within the flows. diff --git a/packages/docs/pages/apps/google-tasks/triggers.md b/packages/docs/pages/apps/google-tasks/triggers.md new file mode 100644 index 0000000..3c6bf3b --- /dev/null +++ b/packages/docs/pages/apps/google-tasks/triggers.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/google-tasks.svg +items: + - name: New completed tasks + desc: Triggers when a task is finished within a specified task list. + - name: New task lists + desc: Triggers when a new task list is created. + - name: New tasks + desc: Triggers when a new task is created. +--- + + + + diff --git a/packages/docs/pages/apps/http-request/actions.md b/packages/docs/pages/apps/http-request/actions.md new file mode 100644 index 0000000..ef1ec65 --- /dev/null +++ b/packages/docs/pages/apps/http-request/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/http-request.svg +items: + - name: Custom request + desc: Makes a custom HTTP request by providing raw details. +--- + + + + diff --git a/packages/docs/pages/apps/http-request/connection.md b/packages/docs/pages/apps/http-request/connection.md new file mode 100644 index 0000000..53c9974 --- /dev/null +++ b/packages/docs/pages/apps/http-request/connection.md @@ -0,0 +1,3 @@ +# HTTP Request + +HTTP Request is a built-in app shipped with Automatisch, and it doesn't need to talk with any other external service to run. So there are no additional steps to use the HTTP Request app. diff --git a/packages/docs/pages/apps/hubspot/actions.md b/packages/docs/pages/apps/hubspot/actions.md new file mode 100644 index 0000000..f2ae774 --- /dev/null +++ b/packages/docs/pages/apps/hubspot/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/hubspot.svg +items: + - name: Create a contact + desc: Create a contact on user's account. +--- + + + + diff --git a/packages/docs/pages/apps/hubspot/connection.md b/packages/docs/pages/apps/hubspot/connection.md new file mode 100644 index 0000000..37864d0 --- /dev/null +++ b/packages/docs/pages/apps/hubspot/connection.md @@ -0,0 +1,22 @@ +# HubSpot + +:::info +This page explains the steps you need to follow to set up the Hubspot connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [HubSpot Developer page](https://developers.hubspot.com/). +2. Login into your developer account. +3. Click on the **Manage apps** button. +4. Click on the **Create app** button. +5. Fill the **Public app name** field with the name of your API app. +6. Go to the **Auth** tab. +7. Fill the **Redirect URL(s)** field with the OAuth Redirect URL from the Automatisch connection creation page. +8. Go to the **Scopes** tab. +9. Select the scopes you want to use with Automatisch. +10. Click on the **Create App** button. +11. Go back to the **Auth** tab. +12. Copy the **Client ID** and **Client Secret** values. +13. Paste the **Client ID** value into Automatisch as **Client ID**, respectively. +14. Paste the **Client Secret** value into Automatisch as **Client Secret**, respectively. +15. Click the **Submit** button on Automatisch. +16. Now, you can start using the HubSpot connection with Automatisch. diff --git a/packages/docs/pages/apps/invoice-ninja/actions.md b/packages/docs/pages/apps/invoice-ninja/actions.md new file mode 100644 index 0000000..d96f531 --- /dev/null +++ b/packages/docs/pages/apps/invoice-ninja/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/invoice-ninja.svg +items: + - name: Create client + desc: Creates a new client. + - name: Create invoice + desc: Creates a new invoice. + - name: Create payment + desc: Creates a new payment. + - name: Create product + desc: Creates a new product. +--- + + + + diff --git a/packages/docs/pages/apps/invoice-ninja/connection.md b/packages/docs/pages/apps/invoice-ninja/connection.md new file mode 100644 index 0000000..e72bbe2 --- /dev/null +++ b/packages/docs/pages/apps/invoice-ninja/connection.md @@ -0,0 +1,16 @@ +# Invoice Ninja + +:::info +This page explains the steps you need to follow to set up the Invoice Ninja connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Invoice Ninja](https://invoiceninja.com/). +2. Login into your account. +3. Click on the your company name. +4. Click on the **Account Management** option. +5. Click on the **Integrations** tab. +6. Click on the **API Tokens**. (You need to have a paid account to be able to see that.) +7. Click on the **New Token** button and create a new api token. +8. Copy **Token** field and paste it to the **API Token** field in Automatisch connection creation page. +9. Click the **Submit** button on Automatisch. +10. Now, you can start using the Invoice Ninja connection with Automatisch. diff --git a/packages/docs/pages/apps/invoice-ninja/triggers.md b/packages/docs/pages/apps/invoice-ninja/triggers.md new file mode 100644 index 0000000..f9d6e9d --- /dev/null +++ b/packages/docs/pages/apps/invoice-ninja/triggers.md @@ -0,0 +1,22 @@ +--- +favicon: /favicons/invoice-ninja.svg +items: + - name: New clients + desc: Triggers when a new client is added. + - name: New credits + desc: Triggers when a new credit is added. + - name: New invoices + desc: Triggers when a new invoice is added. + - name: New payments + desc: Triggers when a new payment is added. + - name: New projects + desc: Triggers when a new project is added. + - name: New quotes + desc: Triggers when a new quote is added. +--- + + + + diff --git a/packages/docs/pages/apps/jotform/connection.md b/packages/docs/pages/apps/jotform/connection.md new file mode 100644 index 0000000..1c1179e --- /dev/null +++ b/packages/docs/pages/apps/jotform/connection.md @@ -0,0 +1,15 @@ +# Jotform + +:::info +This page explains the steps you need to follow to set up the Jotform +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your Jotform account: [https://www.jotform.com/](https://www.jotform.com/). +2. Click on your account image and go to **Settings**. +3. Click on the **API** tab on the left. +4. Click on the **Create New Key** button. +5. Give "Full Access" permission to the created API key. +6. Copy the **API key** from the page to the `API Key` field on Automatisch. +7. Enter your API URL in the respective field if it's different than the default value. For EU, it's https://eu-api.jotform.com. For HIPAA, it's https://hipaa-api.jotform.com. For the Jotform Enterprise customers, it should be the API URL of your Jotform Enterprise instance, e.g. https://subdomain.jotform.com/API or https://your-domain.com/API. More information may be found on the [Jotform API documentation](https://api.jotform.com/docs/). +8. Now, you can start using the Jotform connection with Automatisch. diff --git a/packages/docs/pages/apps/jotform/triggers.md b/packages/docs/pages/apps/jotform/triggers.md new file mode 100644 index 0000000..febf5f5 --- /dev/null +++ b/packages/docs/pages/apps/jotform/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/jotform.svg +items: + - name: New submissions + desc: Triggers when a new submission has been added to a specific form. +--- + + + + diff --git a/packages/docs/pages/apps/mailchimp/actions.md b/packages/docs/pages/apps/mailchimp/actions.md new file mode 100644 index 0000000..4f527d3 --- /dev/null +++ b/packages/docs/pages/apps/mailchimp/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/mailchimp.svg +items: + - name: Create campaign + desc: Creates a new campaign draft. + - name: Send campaign + desc: Sends a campaign draft. +--- + + + + diff --git a/packages/docs/pages/apps/mailchimp/connection.md b/packages/docs/pages/apps/mailchimp/connection.md new file mode 100644 index 0000000..4188699 --- /dev/null +++ b/packages/docs/pages/apps/mailchimp/connection.md @@ -0,0 +1,17 @@ +# Mailchimp + +:::info +This page explains the steps you need to follow to set up the Mailchimp +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your [Mailchimp account](https://mailchimp.com/) to create an app. +2. Click on the account image and go to your **profile** page. +3. Click on the **Extras** tab and choose the **Registered apps**. +4. Click on the **Register An App** button. +5. Fill the registration form. +6. Copy **OAuth Redirect URL** from Automatisch to **Redirect URI** field, and click on the **Create** button. +7. Copy the **Your Client ID** value to the `Client ID` field on Automatisch. +8. Copy the **Your Client Secret** value to the `Client Secret` field on Automatisch. +9. Click **Submit** button on Automatisch. +10. Congrats! Start using your new Mailchimp connection within the flows. diff --git a/packages/docs/pages/apps/mailchimp/triggers.md b/packages/docs/pages/apps/mailchimp/triggers.md new file mode 100644 index 0000000..cf303bf --- /dev/null +++ b/packages/docs/pages/apps/mailchimp/triggers.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/mailchimp.svg +items: + - name: Email opened + desc: Triggers when a recipient opens an email as part of a particular campaign. + - name: New subscribers + desc: Triggers when a new subscriber is appended to an audience. + - name: New unsubscribers + desc: Triggers when any existing subscriber opts out of an audience. +--- + + + + diff --git a/packages/docs/pages/apps/mailerlite/connection.md b/packages/docs/pages/apps/mailerlite/connection.md new file mode 100644 index 0000000..d49c066 --- /dev/null +++ b/packages/docs/pages/apps/mailerlite/connection.md @@ -0,0 +1,15 @@ +# MailerLite + +:::info +This page explains the steps you need to follow to set up the MailerLite +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your MailerLite account: [https://www.mailerlite.com/](https://www.mailerlite.com/). +2. Click on the **Integrations** tab on the left. +3. Click on the **Use** button in the MailerLite API section. +4. Click on the **Generate new token** button. +5. Fill the form and click on the **Create token** button. +6. Copy the token from the popup to the `API Key` field on Automatisch. +7. Write any screen name to be displayed in Automatisch. +8. Now, you can start using the MailerLite connection with Automatisch. diff --git a/packages/docs/pages/apps/mailerlite/triggers.md b/packages/docs/pages/apps/mailerlite/triggers.md new file mode 100644 index 0000000..aff3407 --- /dev/null +++ b/packages/docs/pages/apps/mailerlite/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/mailerlite.svg +items: + - name: Campaign Sent + desc: Triggers when a campaign has been activated. + - name: Spam Complaint + desc: Triggers when a subscriber reports an email as spam. + - name: Subscriber created + desc: Triggers when a new subscriber is added to your mailing list. + - name: Subscriber unsubscribed + desc: Triggers when a subscriber has unsubscribed from your mailing list. +--- + + + + diff --git a/packages/docs/pages/apps/mattermost/actions.md b/packages/docs/pages/apps/mattermost/actions.md new file mode 100644 index 0000000..b4ed063 --- /dev/null +++ b/packages/docs/pages/apps/mattermost/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/mattermost.svg +items: + - name: Send a message to channel + desc: Sends a message to a channel you specify. +--- + + + + diff --git a/packages/docs/pages/apps/mattermost/connection.md b/packages/docs/pages/apps/mattermost/connection.md new file mode 100644 index 0000000..9fa6187 --- /dev/null +++ b/packages/docs/pages/apps/mattermost/connection.md @@ -0,0 +1,19 @@ +# Mattermost + +:::info +This page explains the steps you need to follow to set up the Mattermost +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the `/integrations/oauth2-apps/add` page of your Mattermost server to register a **new OAuth application**. + - You can find details about registering new Mattermost oAuth application at https://docs.mattermost.com/integrations/cloud-oauth-2-0-applications.html#register-your-application-in-mattermost. +2. Fill in the **Display Name** field. +3. Fill in the **Description** field. +4. Fill in the **Homepage** field. +5. Copy **OAuth Redirect URL** from Automatisch to the **Callback URLs** field on Mattermost page. +6. Click on the **Save** button at the end of the form on Mattermost page. +7. Copy the **Client ID** value from the following page to the `Client ID` field on Automatisch. +8. Copy the **Client Secret** value from the same page to the `Client Secret` field on Automatisch. +9. Click **Done** button on MAttermost page. +10. Click **Submit** button on Automatisch. +11. Congrats! Start using your new Mattermost connection within the flows. diff --git a/packages/docs/pages/apps/miro/actions.md b/packages/docs/pages/apps/miro/actions.md new file mode 100644 index 0000000..6cb8da6 --- /dev/null +++ b/packages/docs/pages/apps/miro/actions.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/miro.svg +items: + - name: Create board + desc: Creates a new board. + - name: Copy board + desc: Creates a copy of an existing board. + - name: Create card widget + desc: Creates a new card widget on an existing board. +--- + + + + diff --git a/packages/docs/pages/apps/miro/connection.md b/packages/docs/pages/apps/miro/connection.md new file mode 100644 index 0000000..6508637 --- /dev/null +++ b/packages/docs/pages/apps/miro/connection.md @@ -0,0 +1,19 @@ +# Miro + +:::info +This page explains the steps you need to follow to set up the Miro +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to [link](https://miro.com/signup/) to create a user account in Miro. +2. After signin in, go to [link](https://miro.com/app/dashboard/?createDevTeam=1) to create a developer team. +3. In the **Create new team** modal, select the checkbox and then click **Create team** button. +4. After that, click **Create new app** in Your app section. +5. Fill the field of **App Name**. +6. Select the **Expire user authorization token** checkbox and click the **Create app**. +7. Copy **OAuth Redirect URL** from Automatisch to the **Redirect URI for OAuth2.0** field. +8. Give permissions for **boards**, **identity**, and **team** scopes in Permissions field. +9. Copy the **Client ID** value to the `Client ID` field on Automatisch. +10. Copy the **Client secret** value to the `Client Secret` field on Automatisch. +11. Click **Submit** button on Automatisch. +12. Congrats! Start using your new Miro connection within the flows. diff --git a/packages/docs/pages/apps/mistral-ai/actions.md b/packages/docs/pages/apps/mistral-ai/actions.md new file mode 100644 index 0000000..82f3207 --- /dev/null +++ b/packages/docs/pages/apps/mistral-ai/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/mistral-ai.svg +items: + - name: Create chat completion + desc: Creates a chat completion. +--- + + + + diff --git a/packages/docs/pages/apps/mistral-ai/connection.md b/packages/docs/pages/apps/mistral-ai/connection.md new file mode 100644 index 0000000..4120130 --- /dev/null +++ b/packages/docs/pages/apps/mistral-ai/connection.md @@ -0,0 +1,8 @@ +# Mistral AI + +1. Go to [Your API keys page](https://console.mistral.ai/api-keys/) on Mistral AI. +2. Create a new API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Mistral AI integration with Automatisch! diff --git a/packages/docs/pages/apps/notion/actions.md b/packages/docs/pages/apps/notion/actions.md new file mode 100644 index 0000000..d168016 --- /dev/null +++ b/packages/docs/pages/apps/notion/actions.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/notion.svg +items: + - name: Create database item + desc: Creates an item in a database. + - name: Create page + desc: Creates a page inside a parent page. + - name: Find database item + desc: Searches for an item in a database by property. +--- + + + + diff --git a/packages/docs/pages/apps/notion/connection.md b/packages/docs/pages/apps/notion/connection.md new file mode 100644 index 0000000..363b0c0 --- /dev/null +++ b/packages/docs/pages/apps/notion/connection.md @@ -0,0 +1,22 @@ +# Notion + +:::info +This page explains the steps you need to follow to set up the Notion +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.notion.so/my-integrations) to **create an + integration** on Notion API. +1. Fill out the Name field. +1. Click on the **Submit** button. +1. Go to the **Capabilities** page via the sidebar. +1. Select the **Read user information without email addresses** option under the **User Capabilities** section and then save the changes. +1. Go to the **Distribution** page via the sidebar. +1. Make the integration public by enabling the checkbox. +1. Fill out the necessary fields under the **Organization Information** section. +1. Copy **OAuth Redirect URL** from Automatisch and paste it to the **Redirect URIs** field. +1. Click on the **Submit** button. +1. Accept making the integration public by clicking on the **Continue** button in the dialog. +1. Copy **OAuth client ID** and **OAuth client secret** values and paste them into Automatisch as **Client ID** and **Client Secret**, respectively. +1. Click **Submit** button on Automatisch. +1. Now, you can start using the Notion connection with Automatisch. diff --git a/packages/docs/pages/apps/notion/triggers.md b/packages/docs/pages/apps/notion/triggers.md new file mode 100644 index 0000000..5d68797 --- /dev/null +++ b/packages/docs/pages/apps/notion/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/notion.svg +items: + - name: New database items + desc: Triggers when a new database item is created. + - name: Updated database items + desc: Triggers when there is an update to an item in a chosen database. +--- + + + + diff --git a/packages/docs/pages/apps/ntfy/actions.md b/packages/docs/pages/apps/ntfy/actions.md new file mode 100644 index 0000000..efc1bfb --- /dev/null +++ b/packages/docs/pages/apps/ntfy/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/ntfy.svg +items: + - name: Send a message + desc: Sends a message to a topic you specify. +--- + + + + diff --git a/packages/docs/pages/apps/ntfy/connection.md b/packages/docs/pages/apps/ntfy/connection.md new file mode 100644 index 0000000..d2d81ac --- /dev/null +++ b/packages/docs/pages/apps/ntfy/connection.md @@ -0,0 +1,10 @@ +# Ntfy + +:::info +This page explains the steps you need to follow to set up the Ntfy +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +If you use ntfy.sh, the official public server for this service, you do not need to set up a connection with a custom configuration. It's enough to create one with the default server URL. + +However, if you have a ntfy installation, that's different than ntfy.sh, you need to specify your server URL on Automatisch while creating a connection. Additionally, you may need to provide your username and password if your installation requires authentication. diff --git a/packages/docs/pages/apps/odoo/actions.md b/packages/docs/pages/apps/odoo/actions.md new file mode 100644 index 0000000..e22fdb8 --- /dev/null +++ b/packages/docs/pages/apps/odoo/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/odoo.svg +items: + - name: Create a lead or opportunity + desc: Creates a new CRM record as a lead or opportunity. +--- + + + + \ No newline at end of file diff --git a/packages/docs/pages/apps/odoo/connection.md b/packages/docs/pages/apps/odoo/connection.md new file mode 100644 index 0000000..49b0d27 --- /dev/null +++ b/packages/docs/pages/apps/odoo/connection.md @@ -0,0 +1,16 @@ +# Odoo + +:::info +This page explains the steps you need to follow to set up the Odoo +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +To create a connection, you need to supply the following information: + +1. Fill the **Host Name** field with the Odoo host. +1. Fill the **Port** field with the Odoo port. +1. Fill the **Database Name** field with the Odoo database. +1. Fill the **Email Address** field with the email address of the account that will be intereacting with the database. +1. Fill the **API Key** field with the API key for your Odoo account. + +Odoo's [API documentation](https://www.odoo.com/documentation/latest/developer/reference/external_api.html#api-keys) explains how to create API keys. diff --git a/packages/docs/pages/apps/openai/actions.md b/packages/docs/pages/apps/openai/actions.md new file mode 100644 index 0000000..be3d2ca --- /dev/null +++ b/packages/docs/pages/apps/openai/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/openai.svg +items: + - name: Check moderation + desc: Checks for hate, hate/threatening, self-harm, sexual, sexual/minors, violence, or violence/graphic content in text. + - name: Send prompt + desc: Creates a completion for the provided prompt and parameters. +--- + + + + diff --git a/packages/docs/pages/apps/openai/connection.md b/packages/docs/pages/apps/openai/connection.md new file mode 100644 index 0000000..f6967ed --- /dev/null +++ b/packages/docs/pages/apps/openai/connection.md @@ -0,0 +1,8 @@ +# OpenAI + +1. Go to [API Keys page](https://beta.openai.com/account/api-keys) on OpenAI. +2. Create a new secret key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using OpenAI integration with Automatisch! diff --git a/packages/docs/pages/apps/openrouter/actions.md b/packages/docs/pages/apps/openrouter/actions.md new file mode 100644 index 0000000..394a43a --- /dev/null +++ b/packages/docs/pages/apps/openrouter/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/openrouter.svg +items: + - name: Create chat completion + desc: Creates a chat completion. +--- + + + + diff --git a/packages/docs/pages/apps/openrouter/connection.md b/packages/docs/pages/apps/openrouter/connection.md new file mode 100644 index 0000000..8abc070 --- /dev/null +++ b/packages/docs/pages/apps/openrouter/connection.md @@ -0,0 +1,8 @@ +# OpenRouter + +1. Go to [API Keys page](https://openrouter.ai/settings/keys) on OpenRouter. +2. Create a new key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using OpenRouter integration with Automatisch! diff --git a/packages/docs/pages/apps/perplexity/actions.md b/packages/docs/pages/apps/perplexity/actions.md new file mode 100644 index 0000000..114be04 --- /dev/null +++ b/packages/docs/pages/apps/perplexity/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/perplexity.svg +items: + - name: Send chat prompt + desc: Generates a model's response for the given chat conversation. +--- + + + + diff --git a/packages/docs/pages/apps/perplexity/connection.md b/packages/docs/pages/apps/perplexity/connection.md new file mode 100644 index 0000000..5962f51 --- /dev/null +++ b/packages/docs/pages/apps/perplexity/connection.md @@ -0,0 +1,8 @@ +# Perplexity + +1. Go to [API page](https://www.perplexity.ai/settings/api) on Perplexity. +2. Generate a new API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Perplexity integration with Automatisch! diff --git a/packages/docs/pages/apps/pipedrive/actions.md b/packages/docs/pages/apps/pipedrive/actions.md new file mode 100644 index 0000000..c27b131 --- /dev/null +++ b/packages/docs/pages/apps/pipedrive/actions.md @@ -0,0 +1,22 @@ +--- +favicon: /favicons/pipedrive.svg +items: + - name: Create activity + desc: Creates a new activity. + - name: Create deal + desc: Creates a new deal. + - name: Create lead + desc: Creates a new lead. + - name: Create note + desc: Creates a new note. + - name: Create organization + desc: Creates a new organization. + - name: Create person + desc: Creates a new person. +--- + + + + diff --git a/packages/docs/pages/apps/pipedrive/connection.md b/packages/docs/pages/apps/pipedrive/connection.md new file mode 100644 index 0000000..aa94cfb --- /dev/null +++ b/packages/docs/pages/apps/pipedrive/connection.md @@ -0,0 +1,17 @@ +# Pipedrive + +:::info +This page explains the steps you need to follow to set up the Pipedrive +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to [Pipedrive developers page](https://developers.pipedrive.com/). +2. Sign up for a **Sandbox account** in order to create an app. +3. Click create an app button and then choose **Create private app** option. +4. Write any app name to be displayed in Automatisch. +5. Copy **OAuth Redirect URL** from Automatisch to **Callback URL** field, and click on the **Save** button. +6. Check all options in **OAuth & Access scopes** with full access. +7. Click on the **Save** button. +8. Copy the **Client ID** value to the `Client ID` field on Automatisch. +9. Copy the **Client Secret** value to the `Client Secret` field on Automatisch. +10. Start using Pipedrive integration with Automatisch! diff --git a/packages/docs/pages/apps/pipedrive/triggers.md b/packages/docs/pages/apps/pipedrive/triggers.md new file mode 100644 index 0000000..55ac537 --- /dev/null +++ b/packages/docs/pages/apps/pipedrive/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/pipedrive.svg +items: + - name: New activities + desc: Triggers when a new activity is created. + - name: New deals + desc: Triggers when a new deal is created. + - name: New leads + desc: Triggers when a new lead is created. + - name: New notes + desc: Triggers when a new note is created. +--- + + + + diff --git a/packages/docs/pages/apps/placetel/connection.md b/packages/docs/pages/apps/placetel/connection.md new file mode 100644 index 0000000..a0f0728 --- /dev/null +++ b/packages/docs/pages/apps/placetel/connection.md @@ -0,0 +1,7 @@ +# Placetel + +1. Go to [AppStore page](https://web.placetel.de/integrations) on Placetel. +2. Search for `Web API` and click to `Jetzt buchen`. +3. Click to `Neuen API-Token erstellen` button and copy the API Token. +4. Paste the copied API Token into the `API Token` field in Automatisch. +5. Now, you can start using Placetel integration with Automatisch! diff --git a/packages/docs/pages/apps/placetel/triggers.md b/packages/docs/pages/apps/placetel/triggers.md new file mode 100644 index 0000000..99c86c1 --- /dev/null +++ b/packages/docs/pages/apps/placetel/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/placetel.svg +items: + - name: Hungup call + desc: Triggers when a call is hungup. +--- + + + + diff --git a/packages/docs/pages/apps/postgresql/actions.md b/packages/docs/pages/apps/postgresql/actions.md new file mode 100644 index 0000000..e877edb --- /dev/null +++ b/packages/docs/pages/apps/postgresql/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/postgres.svg +items: + - name: Insert + desc: Create a new row in a table in specified schema. + - name: Update + desc: Update rows found based on the given where clause entries. + - name: Delete + desc: Delete rows found based on the given where clause entries. + - name: SQL query + desc: Executes the given SQL statement. +--- + + + + diff --git a/packages/docs/pages/apps/postgresql/connection.md b/packages/docs/pages/apps/postgresql/connection.md new file mode 100644 index 0000000..c733db8 --- /dev/null +++ b/packages/docs/pages/apps/postgresql/connection.md @@ -0,0 +1,19 @@ +# PostgreSQL + +:::info +This page explains the steps you need to follow to set up the Postgres +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +PostgreSQL is an open-source relational database management system (RDBMS) known for its robustness, reliability, and feature-richness. +It is a powerful and reliable database management system suitable for a wide range of applications, from small projects to enterprise-level systems. + +1. Fill postgreSQL version field with the version that you are using. +2. Fill host address field with the postgres host address. +3. Fill port field with the postgres port. +4. Select wheather to use ssl or not. +5. Fill database name field with the postgres database name. +6. Fill database username field with the postgres username. +7. Fill password field with the postgres password. +8. Click **Submit** button on Automatisch. +9. Now, you can start using the PostgreSQL connection with Automatisch. \ No newline at end of file diff --git a/packages/docs/pages/apps/pushover/actions.md b/packages/docs/pages/apps/pushover/actions.md new file mode 100644 index 0000000..8e799a1 --- /dev/null +++ b/packages/docs/pages/apps/pushover/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/pushover.svg +items: + - name: Send a Pushover Notification + desc: Generates a Pushover notification on the devices you have subscribed to. +--- + + + + diff --git a/packages/docs/pages/apps/pushover/connection.md b/packages/docs/pages/apps/pushover/connection.md new file mode 100644 index 0000000..0281097 --- /dev/null +++ b/packages/docs/pages/apps/pushover/connection.md @@ -0,0 +1,9 @@ +# Pushover + +1. Login to [your account page](https://pushover.net/login) on Pushover. +2. Copy the **Your User Key** value to the **User Key** field in Automatisch connection page. +3. Create a new application from [here](https://pushover.net/apps/build) on Pushover. +4. Copy the **API Token/Key** value to the **API Token** field in Automatisch connection page. +5. Write any screen name to be displayed in Automatisch. +6. Click `Submit`. +7. Start using Pushover integration with Automatisch! diff --git a/packages/docs/pages/apps/reddit/actions.md b/packages/docs/pages/apps/reddit/actions.md new file mode 100644 index 0000000..757bda6 --- /dev/null +++ b/packages/docs/pages/apps/reddit/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/reddit.svg +items: + - name: Create link post + desc: Create a new link post within a subreddit. +--- + + + + diff --git a/packages/docs/pages/apps/reddit/connection.md b/packages/docs/pages/apps/reddit/connection.md new file mode 100644 index 0000000..3cebb99 --- /dev/null +++ b/packages/docs/pages/apps/reddit/connection.md @@ -0,0 +1,15 @@ +# Reddit + +:::info +This page explains the steps you need to follow to set up the Reddit +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to [Reddit apps page](https://www.reddit.com/prefs/apps). +2. Click on the **"are you a developer? create an app..."** button in order to create an app. +3. Fill the **Name** field and choose **web app**. +4. Copy **OAuth Redirect URL** from Automatisch to **redirect uri** field. +5. Click on the **create app** button. +6. Copy the client id below **web app** text to the `Client ID` field on Automatisch. +7. Copy the **secret** value to the `Client Secret` field on Automatisch. +8. Start using Reddit integration with Automatisch! diff --git a/packages/docs/pages/apps/reddit/triggers.md b/packages/docs/pages/apps/reddit/triggers.md new file mode 100644 index 0000000..3fed70f --- /dev/null +++ b/packages/docs/pages/apps/reddit/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/reddit.svg +items: + - name: New posts matching search + desc: Triggers when a search string matches a new post. +--- + + + + diff --git a/packages/docs/pages/apps/removebg/actions.md b/packages/docs/pages/apps/removebg/actions.md new file mode 100644 index 0000000..954ddc3 --- /dev/null +++ b/packages/docs/pages/apps/removebg/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/removebg.svg +items: + - name: Remove Image Background + desc: Remove backgrounds 100% automatically in 5 seconds with one click. +--- + + + + \ No newline at end of file diff --git a/packages/docs/pages/apps/removebg/connection.md b/packages/docs/pages/apps/removebg/connection.md new file mode 100644 index 0000000..d3ff1ec --- /dev/null +++ b/packages/docs/pages/apps/removebg/connection.md @@ -0,0 +1,11 @@ +# Remove.bg + +:::info +This page explains the steps you need to follow to set up the remove.bg +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Login to your remove.bg account: [https://www.remove.bg/](https://www.remove.bg/). +2. Create a new api key: [https://www.remove.bg/dashboard#api-key](https://www.remove.bg/dashboard#api-key). +3. Copy the `API Key` from the page to the `API Key` field on Automatisch. +4. Now, you can start using the remove.bg connection with Automatisch. diff --git a/packages/docs/pages/apps/rss/connection.md b/packages/docs/pages/apps/rss/connection.md new file mode 100644 index 0000000..ce12caa --- /dev/null +++ b/packages/docs/pages/apps/rss/connection.md @@ -0,0 +1,3 @@ +# RSS + +RSS is a built-in app shipped with Automatisch, and it doesn't need to talk with any other external service to run. So there are no additional steps to use the RSS app. diff --git a/packages/docs/pages/apps/rss/triggers.md b/packages/docs/pages/apps/rss/triggers.md new file mode 100644 index 0000000..f18e354 --- /dev/null +++ b/packages/docs/pages/apps/rss/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/rss.svg +items: + - name: New items in feed + desc: Triggers on new RSS feed item. +--- + + + + diff --git a/packages/docs/pages/apps/salesforce/actions.md b/packages/docs/pages/apps/salesforce/actions.md new file mode 100644 index 0000000..1c22193 --- /dev/null +++ b/packages/docs/pages/apps/salesforce/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/salesforce.svg +items: + - name: Create attachment + desc: Creates an attachment of a specified object by given parent ID. + - name: Find record + desc: Finds a record of a specified object by a field and value. + - name: Find partially matching record + desc: Finds a record of a specified object by a field containing a value. + - name: Execute query + desc: Executes a SOQL query in Salesforce. +--- + + + + diff --git a/packages/docs/pages/apps/salesforce/connection.md b/packages/docs/pages/apps/salesforce/connection.md new file mode 100644 index 0000000..c9e5823 --- /dev/null +++ b/packages/docs/pages/apps/salesforce/connection.md @@ -0,0 +1,25 @@ +# Salesforce + +:::info +This page explains the steps you need to follow to set up the Salesforce +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to your Salesforce dasboard. +1. Click on the gear icon in the top right corner and click **Setup** from the dropdown. +1. In the **Platform Tools** section of the sidebar, click **Apps** > **App Manager**. +1. Click the **New Connected App** button. +1. Enter necessary information in the form. +1. Check **Enable OAuth Settings** checkbox. +1. Copy **OAuth Redirect URL** from Automatisch and paste it to the **Callback URL** field. +1. Add any scopes you plan to use in the **Selected OAuth Scopes** section. We suggest `full` and `refresh_token, offline_access` scopes. +1. Uncheck "Require Proof Key for Code Exchange (PKCE) Extension for Supported Authorization Flows" checkbox. +1. Check "Enable Authorization Code and Credentials Flow" checkbox +1. Click on the **Save** button at the bottom of the page. +1. Acknowledge the information and click on the **Continue** button. +1. In the **API (Enable OAuth Settings)** section, click the **Manager Consumer Details** button. +1. You may be asked to verify your identity. To see the consumer key and secret, verify and proceed. +1. Copy the **Consumer Key** value from the page to the `Consumer Key` field on Automatisch. +1. Copy the **Consumer Secret** value from the page to the `Consumer Secret` field on Automatisch. +1. Click **Submit** button on Automatisch. +1. Now, you can start using the Salesforce connection with Automatisch. diff --git a/packages/docs/pages/apps/salesforce/triggers.md b/packages/docs/pages/apps/salesforce/triggers.md new file mode 100644 index 0000000..0ede244 --- /dev/null +++ b/packages/docs/pages/apps/salesforce/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/salesforce.svg +items: + - name: Updated field in records + desc: Triggers when a field is updated in a record. +--- + + + + diff --git a/packages/docs/pages/apps/scheduler/connection.md b/packages/docs/pages/apps/scheduler/connection.md new file mode 100644 index 0000000..46e8cfa --- /dev/null +++ b/packages/docs/pages/apps/scheduler/connection.md @@ -0,0 +1,3 @@ +# Scheduler + +Scheduler is a built-in app shipped with Automatisch, and it doesn't need to talk with any other external service to run. So there are no additional steps to use the Scheduler app. diff --git a/packages/docs/pages/apps/scheduler/triggers.md b/packages/docs/pages/apps/scheduler/triggers.md new file mode 100644 index 0000000..e1ea687 --- /dev/null +++ b/packages/docs/pages/apps/scheduler/triggers.md @@ -0,0 +1,20 @@ +--- +favicon: /favicons/scheduler.svg +items: + - name: Every N minutes + desc: Triggers every N minutes. + - name: Every hour + desc: Triggers every hour. + - name: Every day + desc: Triggers every day. + - name: Every week + desc: Triggers every week. + - name: Every month + desc: Triggers every month. +--- + + + + diff --git a/packages/docs/pages/apps/signalwire/actions.md b/packages/docs/pages/apps/signalwire/actions.md new file mode 100644 index 0000000..f0d726e --- /dev/null +++ b/packages/docs/pages/apps/signalwire/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/signalwire.svg +items: + - name: Send an SMS + desc: Sends an SMS. +--- + + + + diff --git a/packages/docs/pages/apps/signalwire/connection.md b/packages/docs/pages/apps/signalwire/connection.md new file mode 100644 index 0000000..20a6100 --- /dev/null +++ b/packages/docs/pages/apps/signalwire/connection.md @@ -0,0 +1,16 @@ +# SignalWire + +:::info +This page explains the steps you need to follow to set up a SignalWire connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the SignalWire API page in your respective project (https://{space}.signalwire.com/credentials) +2. Copy **Project ID** and paste it to the **Project ID** field on the + Automatisch connection creation page. +3. Create/Copy **API Token** and paste it to the **API Token** field on the + Automatisch connection creation page. +4. Select your **Region** (US for most users). +5. Provide your **Space Name** from the URL and paste it to the **Space NAME** field on the + Automatisch connection creation page. +6. Click **Submit** button on Automatisch. +7. Now you can start using the new SignalWire connection! diff --git a/packages/docs/pages/apps/signalwire/triggers.md b/packages/docs/pages/apps/signalwire/triggers.md new file mode 100644 index 0000000..8d412be --- /dev/null +++ b/packages/docs/pages/apps/signalwire/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/signalwire.svg +items: + - name: Receive Call + desc: Triggers when a new call is received. + - name: Receive SMS + desc: Triggers when a new SMS is received. +--- + + + + diff --git a/packages/docs/pages/apps/slack/actions.md b/packages/docs/pages/apps/slack/actions.md new file mode 100644 index 0000000..90de241 --- /dev/null +++ b/packages/docs/pages/apps/slack/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/slack.svg +items: + - name: Find a message + desc: Finds a message using the Slack search feature. + - name: Find user by email + desc: Finds a user by email. + - name: Send a message to channel + desc: Sends a message to a channel you specify. + - name: Send a direct message + desc: Sends a direct message to a user or yourself from the Slackbot. +--- + + + + diff --git a/packages/docs/pages/apps/slack/connection.md b/packages/docs/pages/apps/slack/connection.md new file mode 100644 index 0000000..d004994 --- /dev/null +++ b/packages/docs/pages/apps/slack/connection.md @@ -0,0 +1,25 @@ +# Slack + +:::info +This page explains the steps you need to follow to set up the Slack connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://api.slack.com/apps?new_app=1) to **create an app** + on Slack API. +1. Select **From scratch**. +1. Enter **App name**. +1. Pick the workspace you would like to use with the Slack connection. +1. Click on **Create App** button. +1. Copy **Client ID** and **Client Secret** values and save them to use later. +1. Go to **OAuth & Permissions** page. +1. Copy **OAuth Redirect URL** from Automatisch and add it in Redirect URLs. Don't forget to save it after adding it by clicking **Save URLs** button! +1. Go to **Bot Token Scopes** and add `chat:write.customize` along with `chat:write` scope to enable the bot functionality. + +:::warning HTTPS required! + +Slack does **not** allow non-secure URLs in redirect URLs. Therefore, you will need to serve Automatisch via HTTPS protocol. +::: + +10. Paste **Client ID** and **Client Secret** values you have saved earlier and paste them into Automatisch as **Consumer Key** and **Consumer Secret**, respectively. +1. Click **Submit** button on Automatisch. +1. Now, you can start using the Slack connection with Automatisch. diff --git a/packages/docs/pages/apps/smtp/actions.md b/packages/docs/pages/apps/smtp/actions.md new file mode 100644 index 0000000..b6feb75 --- /dev/null +++ b/packages/docs/pages/apps/smtp/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/smtp.svg +items: + - name: Send an email + desc: Sends an email. +--- + + + + diff --git a/packages/docs/pages/apps/smtp/connection.md b/packages/docs/pages/apps/smtp/connection.md new file mode 100644 index 0000000..5b4f692 --- /dev/null +++ b/packages/docs/pages/apps/smtp/connection.md @@ -0,0 +1,16 @@ +# SMTP + +:::info +This page explains the steps you need to follow to set up the SMTP connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +SMTP is a protocol that allows you to send emails. It's a very common protocol and it's used by many email providers. You need to provide the following information to send emails from Automatisch by using SMTP connection. + +1. Fill host address field with the SMTP host address. +2. Fill username field with the SMTP username. +3. Fill password field with the SMTP password. +4. Select wheather to use TLS or not. +5. Fill port field with the SMTP port. +6. Fill from field with the email address you want to use as the sender. +7. Click **Submit** button on Automatisch. +8. Now, you can start using the SMTP connection with Automatisch. diff --git a/packages/docs/pages/apps/spotify/actions.md b/packages/docs/pages/apps/spotify/actions.md new file mode 100644 index 0000000..607264d --- /dev/null +++ b/packages/docs/pages/apps/spotify/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/spotify.svg +items: + - name: Create playlist + desc: Create a playlist on user's account. +--- + + + + diff --git a/packages/docs/pages/apps/spotify/connection.md b/packages/docs/pages/apps/spotify/connection.md new file mode 100644 index 0000000..dad852f --- /dev/null +++ b/packages/docs/pages/apps/spotify/connection.md @@ -0,0 +1,20 @@ +# Spotify + +:::info +This page explains the steps you need to follow to set up the Spotify connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://developer.spotify.com/dashboard/applications) to **create an app** + on Spotify API. +1. Click login button if you're not logged in. +1. Click **Create an app** button. +1. Enter **App name** and **App description**. +1. Click on **Create App** button. +1. **Client ID** will be visible on the screen. +1. Click **Show Client Secret** button to see client secret. +1. Copy **Client ID** and **Client Secret** values and save them to use later. +1. Click **Edit settings** button. +1. Copy **OAuth Redirect URL** from Automatisch and add it in Redirect URLs. Don't forget to save it after adding it by clicking **Add** button! +1. Paste **Client ID** and **Client Secret** values you have saved earlier and paste them into Automatisch as **Client Id** and **Client Secret**, respectively. +1. Click **Submit** button on Automatisch. +1. Now, you can start using the Spotify connection with Automatisch. diff --git a/packages/docs/pages/apps/strava/actions.md b/packages/docs/pages/apps/strava/actions.md new file mode 100644 index 0000000..8e877fb --- /dev/null +++ b/packages/docs/pages/apps/strava/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/strava.svg +items: + - name: Create totals and stats report + desc: Creates a report with recent, year to date, and all time stats of your activities. +--- + + + + diff --git a/packages/docs/pages/apps/strava/connection.md b/packages/docs/pages/apps/strava/connection.md new file mode 100644 index 0000000..0060fc0 --- /dev/null +++ b/packages/docs/pages/apps/strava/connection.md @@ -0,0 +1,14 @@ +# Strava + +:::info +This page explains the steps you need to follow to set up the Strava connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.strava.com/settings/api) to create an app on Strava API. +1. Click on **Upload** button to upload your APP icon. +1. Click on **Edit** button. +1. Copy **OAuth Redirect URL** from Automatisch and paste it in **Authorization Callback Domain** +1. Click on **Save** button. +1. Copy **Client ID** from Strava and paste it in **Client ID** field on Automatisch. +1. Copy **Client Secret** from Strava and paste it in **Client Secret** field on Automatisch. +1. Now, you can start using the Strava connection with Automatisch. diff --git a/packages/docs/pages/apps/stripe/connection.md b/packages/docs/pages/apps/stripe/connection.md new file mode 100644 index 0000000..0cd174e --- /dev/null +++ b/packages/docs/pages/apps/stripe/connection.md @@ -0,0 +1,14 @@ +# Stripe + +:::info +This page explains the steps you need to follow to set up the Stripe connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +:::info +You are free to use the **Testing secret key** instead of the productive secret key as well. +::: + +1. Go to the [Stripe Dashboard > Developer > API keys](https://dashboard.stripe.com/apikeys) +2. Click on **Reveal live key** in the table row **Secret key** and copy the now shown secret key +3. Paste the **Secret key** in the named field in Automatisch and assign a display name for the connection. +4. Congrats! You can start using the new Stripe connection! diff --git a/packages/docs/pages/apps/stripe/triggers.md b/packages/docs/pages/apps/stripe/triggers.md new file mode 100644 index 0000000..1e79a20 --- /dev/null +++ b/packages/docs/pages/apps/stripe/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/stripe.svg +items: + - name: New payouts + desc: Triggers when stripe sent a payout to a third-party bank account or vice versa. + org: Stripe documentation + orgLink: https://stripe.com/docs/api/payouts/object + - name: New balance transactions + desc: Triggers when a fund has been moved through your stripe account. + org: Stripe documentation + orgLink: https://stripe.com/docs/api/balance_transactions/object +--- + + + + diff --git a/packages/docs/pages/apps/telegram-bot/actions.md b/packages/docs/pages/apps/telegram-bot/actions.md new file mode 100644 index 0000000..261d241 --- /dev/null +++ b/packages/docs/pages/apps/telegram-bot/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/telegram-bot.svg +items: + - name: Send a message + desc: Sends a message to a chat you specify. +--- + + + + diff --git a/packages/docs/pages/apps/telegram-bot/connection.md b/packages/docs/pages/apps/telegram-bot/connection.md new file mode 100644 index 0000000..4a015cc --- /dev/null +++ b/packages/docs/pages/apps/telegram-bot/connection.md @@ -0,0 +1,14 @@ +# Telegram + +:::info +This page explains the steps you need to follow to set up the Telegram +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Start a chat with [Botfather](https://telegram.me/BotFather). +1. Enter `/newbot`. +1. Enter a name for your bot. +1. Enter a username for your bot. +1. Copy the **token** value from the answer to the **Bot token** field on Automatisch. +1. Click **Submit** button on Automatisch. +1. Congrats! Start using your new Telegram connection within the flows. diff --git a/packages/docs/pages/apps/todoist/actions.md b/packages/docs/pages/apps/todoist/actions.md new file mode 100644 index 0000000..5dcceed --- /dev/null +++ b/packages/docs/pages/apps/todoist/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/todoist.svg +items: + - name: Create task + desc: Creates a task in Todoist. +--- + + + + diff --git a/packages/docs/pages/apps/todoist/connection.md b/packages/docs/pages/apps/todoist/connection.md new file mode 100644 index 0000000..1027483 --- /dev/null +++ b/packages/docs/pages/apps/todoist/connection.md @@ -0,0 +1,14 @@ +# Todoist + +:::info +This page explains the steps you need to follow to set up the Todoist connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the account [App Management page](https://developer.todoist.com/appconsole.html) to register a **new OAuth application** on Todoist. +1. Fill **App name** and **App service URL**. +1. Copy **OAuth Redirect URL** from Automatisch to **OAuth redirect URL** field on Todoist page. +1. Click on the **Save settings** button on the Todoist page. +1. Copy the **Client ID** and **Client secret** values from the Todoist page to the corresponding fields on Automatisch. +1. Enter a memorable name for your connection in the **Screen Name** field. +1. Click the **Submit** button on Automatisch. +1. Congrats! Start using your new Todoist connection within the flows. diff --git a/packages/docs/pages/apps/todoist/triggers.md b/packages/docs/pages/apps/todoist/triggers.md new file mode 100644 index 0000000..a1c6f46 --- /dev/null +++ b/packages/docs/pages/apps/todoist/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/todoist.svg +items: + - name: Get tasks + desc: Finds tasks in Todoist, optionally matching specified parameters. +--- + + + + diff --git a/packages/docs/pages/apps/together-ai/actions.md b/packages/docs/pages/apps/together-ai/actions.md new file mode 100644 index 0000000..4152212 --- /dev/null +++ b/packages/docs/pages/apps/together-ai/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/together-ai.svg +items: + - name: Create chat completion + desc: Queries a chat model. + - name: Create completion + desc: Queries a language, code, or image model. +--- + + + + diff --git a/packages/docs/pages/apps/together-ai/connection.md b/packages/docs/pages/apps/together-ai/connection.md new file mode 100644 index 0000000..e40983a --- /dev/null +++ b/packages/docs/pages/apps/together-ai/connection.md @@ -0,0 +1,8 @@ +# Together AI + +1. Go to [API Keys page](https://api.together.ai/settings/api-keys) on Together AI. +2. Copy your API key. +3. Paste the key into the `API Key` field in Automatisch. +4. Write any screen name to be displayed in Automatisch. +5. Click `Save`. +6. Start using Together AI integration with Automatisch! diff --git a/packages/docs/pages/apps/trello/actions.md b/packages/docs/pages/apps/trello/actions.md new file mode 100644 index 0000000..8ece515 --- /dev/null +++ b/packages/docs/pages/apps/trello/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/trello.svg +items: + - name: Create card + desc: Creates a new card within a specified board and list. +--- + + + + diff --git a/packages/docs/pages/apps/trello/connection.md b/packages/docs/pages/apps/trello/connection.md new file mode 100644 index 0000000..8a60acc --- /dev/null +++ b/packages/docs/pages/apps/trello/connection.md @@ -0,0 +1,16 @@ +# Trello + +:::info +This page explains the steps you need to follow to set up the Trello +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://trello.com/power-ups/admin) in order to create a Trello Power-Up. +2. Click on the **New** button. +3. Fill the form fields and click the **Create** button. +4. Click on the **Generate a new API key** button. +5. A popup will open. Click the **Generate a new API key** button again. +6. Copy **OAuth Redirect URL** from Automatisch and paste it in **Allowed origins** and click on the **Add** button. +7. Copy the **API key** value to the **API key** field on Automatisch. +8. Click **Submit** button on Automatisch. +9. Congrats! Start using your new Trello connection within the flows. diff --git a/packages/docs/pages/apps/twilio/actions.md b/packages/docs/pages/apps/twilio/actions.md new file mode 100644 index 0000000..168afe2 --- /dev/null +++ b/packages/docs/pages/apps/twilio/actions.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/twilio.svg +items: + - name: Send an SMS + desc: Sends an SMS. +--- + + + + diff --git a/packages/docs/pages/apps/twilio/connection.md b/packages/docs/pages/apps/twilio/connection.md new file mode 100644 index 0000000..50460bf --- /dev/null +++ b/packages/docs/pages/apps/twilio/connection.md @@ -0,0 +1,13 @@ +# Twilio + +:::info +This page explains the steps you need to follow to set up the Twilio connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the Twilio [console page](https://console.twilio.com) +2. Copy **Account SID** and paste it to **Account SID** field on the + Automatisch connection creation page. +3. Copy **Auth Token** and paste it to **Auth Token** field on the + Automatisch connection creation page. +4. Click **Submit** button on Automatisch. +5. Now you can start using the new Twilio connection! diff --git a/packages/docs/pages/apps/twilio/triggers.md b/packages/docs/pages/apps/twilio/triggers.md new file mode 100644 index 0000000..5199832 --- /dev/null +++ b/packages/docs/pages/apps/twilio/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/twilio.svg +items: + - name: Receive SMS + desc: Triggers when a new SMS is received. +--- + + + + diff --git a/packages/docs/pages/apps/twitter/actions.md b/packages/docs/pages/apps/twitter/actions.md new file mode 100644 index 0000000..6e12ef9 --- /dev/null +++ b/packages/docs/pages/apps/twitter/actions.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/twitter.svg +items: + - name: Create tweet + desc: Create a tweet. + - name: Search user + desc: Search a user. +--- + + + + diff --git a/packages/docs/pages/apps/twitter/connection.md b/packages/docs/pages/apps/twitter/connection.md new file mode 100644 index 0000000..85ab307 --- /dev/null +++ b/packages/docs/pages/apps/twitter/connection.md @@ -0,0 +1,25 @@ +# Twitter + +:::info +This page explains the steps you need to follow to set up the Twitter connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Twitter Developer Portal](https://developer.twitter.com/en/portal/projects-and-apps), complete the questionnaire and click the **Let's do this** button. +2. Accept terms & conditions on the following page and click **Submit**. + +:::warning +If you see an error saying `There was a problem completing your request. User must have a verified phone number on file prior to submitting application.` Go to the [phone settings page](https://twitter.com/settings/phone) and set up your phone number to be able to continue on step 2. +::: + +3. You will get a verification email from Twitter. Click on **Confirm your email** button. +4. Fill out the **App name** field and click on the **Get keys** button. +5. Copy **API Key** and **API Key Secret** values and save them to use later. +6. Click **Dashboard** and **Yes, I saved them** buttons, respectively. +7. Go to the **App settings** link in the project section you have created. +8. Go to the **User authentication settings** section and click **Set up**. +9. Enable **OAuth 1.0a** on the following page. +10. In the **OAuth 1.0A Settings** section, select **Read and write** option. +11. Copy **OAuth Redirect URL** from Automatisch and paste it to the **Callback URI / Redirect URL** field. +12. Fill **Website URL** and click **Save**. +13. Paste **API Key** and **API Key Secret** values you have saved from the 5th step and paste them into Automatisch as **API Key** and **API Secret**, respectively. +14. Congrats! You can start using the new Twitter connection! diff --git a/packages/docs/pages/apps/twitter/triggers.md b/packages/docs/pages/apps/twitter/triggers.md new file mode 100644 index 0000000..0494ea1 --- /dev/null +++ b/packages/docs/pages/apps/twitter/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/twitter.svg +items: + - name: My tweets + desc: Triggers when you tweet something new. + - name: New followers of me + desc: Triggers when you have a new follower. + - name: Search tweets + desc: Triggers when there is a new tweet containing a specific keyword, phrase, username or hashtag. + - name: User tweets + desc: Triggers when a specific user tweet something new. +--- + + + + diff --git a/packages/docs/pages/apps/typeform/connection.md b/packages/docs/pages/apps/typeform/connection.md new file mode 100644 index 0000000..f3db56b --- /dev/null +++ b/packages/docs/pages/apps/typeform/connection.md @@ -0,0 +1,14 @@ +# Typeform + +:::info +This page explains the steps you need to follow to set up the Typeform connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://admin.typeform.com/user) and click on **Developer apps** in the sidebar. +2. Click on the **Register a new app** button. +3. Fill **App name** and **App website**, and **Developer email** fields. +4. Copy **OAuth Redirect URL** from Automatisch to **Redirect URI(s)** field on the Typeform page. +5. Click on the **Register app** button. +6. Copy **Client ID** and **Client Secret** values from Typeform to Automatisch. +7. Click **Submit** button on Automatisch. +8. Congrats! Typeform connection is created. diff --git a/packages/docs/pages/apps/typeform/triggers.md b/packages/docs/pages/apps/typeform/triggers.md new file mode 100644 index 0000000..2bfab4d --- /dev/null +++ b/packages/docs/pages/apps/typeform/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/typeform.svg +items: + - name: New entry + desc: Triggers when a new form is submitted. +--- + + + + diff --git a/packages/docs/pages/apps/virtualq/actions.md b/packages/docs/pages/apps/virtualq/actions.md new file mode 100644 index 0000000..ee3be67 --- /dev/null +++ b/packages/docs/pages/apps/virtualq/actions.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/virtualq.svg +items: + - name: Create waiter + desc: Creates a waiter. + - name: Delete waiter + desc: Deletes a waiter. + - name: Show waiter + desc: Shows a waiter. + - name: Update waiter + desc: Updates a waiter. +--- + + + + diff --git a/packages/docs/pages/apps/virtualq/connection.md b/packages/docs/pages/apps/virtualq/connection.md new file mode 100644 index 0000000..59b3c5b --- /dev/null +++ b/packages/docs/pages/apps/virtualq/connection.md @@ -0,0 +1,13 @@ +# VirtualQ + +:::info +This page explains the steps you need to follow to set up a VirtualQ connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the VirtualQ dashboard and open "Contact Center" page (https://dashboard.virtualq.tech/). +2. Open **API Tokens** tab. +3. Click the **New Token** button. +4. Provide a description for your token in the **Optional description** field and submit the form. +5. Copy the shown token and paste it to the **API Key** field on the Automatisch connection creation page. +6. Click **Submit** button on Automatisch. +7. Now you can start using the new VirtualQ connection! diff --git a/packages/docs/pages/apps/vtiger-crm/actions.md b/packages/docs/pages/apps/vtiger-crm/actions.md new file mode 100644 index 0000000..b498505 --- /dev/null +++ b/packages/docs/pages/apps/vtiger-crm/actions.md @@ -0,0 +1,20 @@ +--- +favicon: /favicons/vtiger-crm.svg +items: + - name: Create case + desc: Create a new case. + - name: Create contact + desc: Create a new contact. + - name: Create opportunity + desc: Create a new opportunity. + - name: Create todo + desc: Create a new todo. + - name: Create lead + desc: Create a new lead. +--- + + + + diff --git a/packages/docs/pages/apps/vtiger-crm/connection.md b/packages/docs/pages/apps/vtiger-crm/connection.md new file mode 100644 index 0000000..101b876 --- /dev/null +++ b/packages/docs/pages/apps/vtiger-crm/connection.md @@ -0,0 +1,13 @@ +# Vtiger CRM + +:::info +This page explains the steps you need to follow to set up the Vtiger CRM connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.vtiger.com/) and create an account. +2. Go to **My Preferences** of your account. +3. Copy **Access Key** value from Vtiger CRM to Automatisch. +4. Fill **Username** field as your Vtiger CRM account email on Automatisch. +5. Fill **Domain** field as if your dashboard url is `https://acmeco.od1.vtiger.com`, paste `acmeco.od1` to the field on Automatisch. +6. Click **Submit** button on Automatisch. +7. Congrats! Vtiger CRM connection is created. diff --git a/packages/docs/pages/apps/vtiger-crm/triggers.md b/packages/docs/pages/apps/vtiger-crm/triggers.md new file mode 100644 index 0000000..57e8869 --- /dev/null +++ b/packages/docs/pages/apps/vtiger-crm/triggers.md @@ -0,0 +1,22 @@ +--- +favicon: /favicons/vtiger-crm.svg +items: + - name: New cases + desc: Triggers when a new case is created. + - name: New contacts + desc: Triggers when a new contact is created. + - name: New invoices + desc: Triggers when a new invoice is created. + - name: New leads + desc: Triggers when a new lead is created. + - name: New opportunities + desc: Triggers when a new opportunity is created. + - name: New todos + desc: Triggers when a new todo is created. +--- + + + + diff --git a/packages/docs/pages/apps/webhooks/connection.md b/packages/docs/pages/apps/webhooks/connection.md new file mode 100644 index 0000000..a38b7e9 --- /dev/null +++ b/packages/docs/pages/apps/webhooks/connection.md @@ -0,0 +1,7 @@ +# Webhooks + +Webhooks is a built-in app shipped with Automatisch, and it doesn't need to authenticate with any other external service to run. + +## How to use + +You will be given a webhook URL in the test substep on the editor page, and you can use it to send a GET, POST, PUT, or PATCH request to Automatisch to trigger the flow. diff --git a/packages/docs/pages/apps/webhooks/triggers.md b/packages/docs/pages/apps/webhooks/triggers.md new file mode 100644 index 0000000..9dda3c3 --- /dev/null +++ b/packages/docs/pages/apps/webhooks/triggers.md @@ -0,0 +1,12 @@ +--- +favicon: /favicons/webhooks.svg +items: + - name: Catch raw webhook + desc: Triggers when the webhook receives a request. +--- + + + + diff --git a/packages/docs/pages/apps/wordpress/connection.md b/packages/docs/pages/apps/wordpress/connection.md new file mode 100644 index 0000000..9cce51b --- /dev/null +++ b/packages/docs/pages/apps/wordpress/connection.md @@ -0,0 +1,9 @@ +# WordPress + +:::info +This page explains the steps you need to follow to set up the WordPress connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Add your WordPress public URL (without any path in the address) in the **WordPress instance URL** field on Automatisch. +1. Click **Submit** button on Automatisch. +1. Congrats! Start using your new WordPress connection within the flows. diff --git a/packages/docs/pages/apps/wordpress/triggers.md b/packages/docs/pages/apps/wordpress/triggers.md new file mode 100644 index 0000000..cee4ebf --- /dev/null +++ b/packages/docs/pages/apps/wordpress/triggers.md @@ -0,0 +1,16 @@ +--- +favicon: /favicons/wordpress.svg +items: + - name: New comment + desc: Triggers when a new comment is created. + - name: New page + desc: Triggers when a new page is created. + - name: New post + desc: Triggers when a new post is created. +--- + + + + diff --git a/packages/docs/pages/apps/xero/connection.md b/packages/docs/pages/apps/xero/connection.md new file mode 100644 index 0000000..d9a43d3 --- /dev/null +++ b/packages/docs/pages/apps/xero/connection.md @@ -0,0 +1,18 @@ +# Xero + +:::info +This page explains the steps you need to follow to set up the Xero +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to [Xero developers page](https://developer.xero.com/app/manage). +2. Click **New app** button in order to create an app. +3. Fill the **Name** field and choose **Web App**. +4. Fill the **Company or application URL** field. +5. Copy **OAuth Redirect URL** from Automatisch to **Redirect URL** field. +6. Check the terms and conditions checkbox. +7. Click on the **Create app** button. +8. Go to **Configuration** page and click the **Generate Client Secret** button. +9. Copy the **Client id** value to the `Client ID` field on Automatisch. +10. Copy the **Client secret 1** value to the `Client Secret` field on Automatisch. +11. Start using Xero integration with Automatisch! diff --git a/packages/docs/pages/apps/xero/triggers.md b/packages/docs/pages/apps/xero/triggers.md new file mode 100644 index 0000000..23e5eb9 --- /dev/null +++ b/packages/docs/pages/apps/xero/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/xero.svg +items: + - name: New bank transactions + desc: Triggers when a new bank transaction occurs. + - name: New payments + desc: Triggers when a new payment is received. +--- + + + + diff --git a/packages/docs/pages/apps/you-need-a-budget/connection.md b/packages/docs/pages/apps/you-need-a-budget/connection.md new file mode 100644 index 0000000..be7307d --- /dev/null +++ b/packages/docs/pages/apps/you-need-a-budget/connection.md @@ -0,0 +1,19 @@ +# You Need A Budget + +:::info +This page explains the steps you need to follow to set up the You Need A Budget +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [link](https://www.ynab.com/) and login your account. +2. Click on the account name in the top left and go to **Account Settings**. +3. Click on the **Developer Settings**. +4. Click on the **New Application** under the **OAuth Applications** section. +5. Fill the new application form. +6. Copy **OAuth Redirect URL** from Automatisch and paste it in **Redirect URI(s)** section. +7. Enable the option **Enable default budget selection when users authorize this application**. +8. Click on the **Save Application** button. +9. Copy the **Client ID** value to the **Client ID** field on Automatisch. +10. Copy the **Client Secret** value to the **Client Secret** field on Automatisch. +11. Fill the **Screen Name** field on Automatisch. +12. Congrats! Start using your new You Need A Budget connection within the flows. diff --git a/packages/docs/pages/apps/you-need-a-budget/triggers.md b/packages/docs/pages/apps/you-need-a-budget/triggers.md new file mode 100644 index 0000000..4394dd0 --- /dev/null +++ b/packages/docs/pages/apps/you-need-a-budget/triggers.md @@ -0,0 +1,18 @@ +--- +favicon: /favicons/you-need-a-budget.svg +items: + - name: Category overspent + desc: Triggers when a category exceeds its budget, resulting in a negative balance. + - name: Goal completed + desc: Triggers when a goal is completed. + - name: Low account balance + desc: Triggers when the balance of a Checking or Savings account falls below a specified amount within a given month. + - name: New transactions + desc: Triggers when a new transaction is created. +--- + + + + diff --git a/packages/docs/pages/apps/youtube/connection.md b/packages/docs/pages/apps/youtube/connection.md new file mode 100644 index 0000000..3f3f7cd --- /dev/null +++ b/packages/docs/pages/apps/youtube/connection.md @@ -0,0 +1,28 @@ +# Youtube + +:::info +This page explains the steps you need to follow to set up the Youtube +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project. +2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button. +3. Enter a name for your project and click on the **Create** button. +4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console. +5. Search for **Youtube Data API** in the search bar and click on it. +6. Click on the **Enable** button to enable the API. +7. Repeat steps 5 and 6 for the **People API**. +8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console. +9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button. +10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button. +11. Skip adding or removing scopes and click on the **Save and Continue** button. +12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing". +13. Click on the **Save and Continue** button and now you have configured the consent screen. +14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console. +15. Click on the **Create Credentials** button and select the **OAuth client ID** option. +16. Select the application type as **Web application** and fill the **Name** field. +17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button. +18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch. +19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch. +20. Click **Submit** button on Automatisch. +21. Congrats! Start using your new Youtube connection within the flows. diff --git a/packages/docs/pages/apps/youtube/triggers.md b/packages/docs/pages/apps/youtube/triggers.md new file mode 100644 index 0000000..f0a80c1 --- /dev/null +++ b/packages/docs/pages/apps/youtube/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/youtube.svg +items: + - name: New video in channel + desc: Triggers when a new video is published to a specific Youtube channel. + - name: New video by search + desc: Triggers when a new video is uploaded that matches a specific search string. +--- + + + + diff --git a/packages/docs/pages/apps/zendesk/actions.md b/packages/docs/pages/apps/zendesk/actions.md new file mode 100644 index 0000000..4a1f3bb --- /dev/null +++ b/packages/docs/pages/apps/zendesk/actions.md @@ -0,0 +1,22 @@ +--- +favicon: /favicons/zendesk.svg +items: + - name: Create ticket + desc: Creates a new ticket. + - name: Create user + desc: Creates a new user. + - name: Delete ticket + desc: Deletes an existing ticket. + - name: Delete user + desc: Deletes an existing user. + - name: Find ticket + desc: Finds an existing ticket. + - name: Update ticket + desc: Modify the status of an existing ticket or append comments. +--- + + + + diff --git a/packages/docs/pages/apps/zendesk/connection.md b/packages/docs/pages/apps/zendesk/connection.md new file mode 100644 index 0000000..08d074d --- /dev/null +++ b/packages/docs/pages/apps/zendesk/connection.md @@ -0,0 +1,21 @@ +# Zendesk + +:::info +This page explains the steps you need to follow to set up the Zendesk +connection in Automatisch. If any of the steps are outdated, please let us know! +::: + +1. Fill `Zendesk Subdomain URL` with your dashboard URL, for example: `https://yourcompany.zendesk.com`. +2. Go to your Zendesk dashboard. +3. Click on **Zendesk Products** at the top right corner and click **Admin Center** from the dropdown. +4. Enter **App and integrations** section. +5. Click on **Zendesk API** from the sidebar. +6. Click on **OAuth Clients** tab. +7. Click on **Add OAuth Client** button. +8. Enter necessary information in the form. +9. Copy **OAuth Redirect URL** from Automatisch to **Redirect URLs** field in the form. +10. Enter your preferred client ID value in **Unique Identifier** field. +11. Save the form to complete creating the OAuth client. +12. Copy the `Unique identifier` value from the page to the `Client ID` field on Automatisch. +13. Copy the `Secret` value from the page to the `Client Secret` field on Automatisch. +14. Now, you can start using the Zendesk connection with Automatisch. diff --git a/packages/docs/pages/apps/zendesk/triggers.md b/packages/docs/pages/apps/zendesk/triggers.md new file mode 100644 index 0000000..0846d7b --- /dev/null +++ b/packages/docs/pages/apps/zendesk/triggers.md @@ -0,0 +1,14 @@ +--- +favicon: /favicons/zendesk.svg +items: + - name: New tickets + desc: Triggers when a new ticket is created in a specific view. + - name: New users + desc: Triggers upon the creation of a new user. +--- + + + + diff --git a/packages/docs/pages/assets/flow-900.png b/packages/docs/pages/assets/flow-900.png new file mode 100644 index 0000000000000000000000000000000000000000..25f6d1a358ca49cc715727f88e5a0009b8f1755d GIT binary patch literal 73440 zcmb@uby!qi^fyW~^e7+=LkLPE(lvCbfFLC~v~+jM&}kqbC89`&NcYgvAl=;z-Ejv8 zzrXjs_mBJB=f39;o|$v@UhA`Ruf5J5!c~>!32-TKQBY6_6cuFDP*AV|C@5GU02bno z85PAF6ja(DiZYU~JW#i$Q7!Z>$gy|vNFwmRgF*O-Xc+mhTntF~1C9&e_g4h#p$hhD zAQbg?R5WCnmcIwV04h)wgQ8O&44VC?=HLggtLlUaz2+jA&OYtLGo5SqWNIk)+U}K| zW*ch-zkq=M%=q}(!Q9;3!Tdbr%pMhi0CBxPdU){cI03)-iDs2f)xf#eY1XuzislRd zCDbSkV(5=&-+4*=D?Y5LMp|Ogj5)iPvnD^c3 ziEqQ@(OkO|zW}k&k54sheIkB0KMxar{O;tE_&5~b*&$RmrSCZtNffz!yyQJ+;yJn0``8H`fWApFVJC!D=%h{whRbR1q<#Y zCj)`!;V=Jvlq=Fw&Vna!#ae`h1g*xJ4m_mpJ>UZ~^HGz|e=8 z+JEja4@ysyc!rY5(d3U2G|t(r@AbKN(>uX?p+tNCr-)*=FSeM%n(1Rr?V8)aAn!voN;Po z9DufoFhvCf0N=jA4M4qbL=S!wh+8~v zX}+Dv)4bK46u{EA()VUtCds+?_i8hokalh}#s5mK7qfXb7`B6c2S`vGFQ^P8NX}(a zby=00`@8$M{!;Wu@0?KB4#WJ$of$xcpNi@>AiF)GtZoLc9IqgxH}+g6i^m&D%h5{( zOFc{5f21K*MLP@`cM||mw#B(0MO~FQRusvTvp1|HNmmuFZqlC5*cc!zfk=gdsq=3% zpZXJ@?PW(KtK^o+FqGDQug$3~IETyWhC$Jg2pe+^Bh4#2XC+s>olXo21qeSczgV_i zjvi)&R7u}a`qF9``AIO*(4)>zqR&vePz;zf8@+-Uq)&pgS@HNz&A)b!j6#ynd}!U% z`!EZb%{_HJO+2k_ePkcq4bVG0HahciDA8`Q;g7T?dSI+cTv}UDTT)xLPkPI&m;h;3 zu*dag6W7%n8#}&(>n}Ppnloc(MF7k@!#$X#>*hkJQuyMh^;1e5o#@{Ew*8U)mHp$_ zexa&&ZdWUz?Z+jYs%#=i9ifdJ@!MWb*XO?h7_j|oqw9UgCrK@xo#A1_OYHXS_8j(w zwbK94y6jyW$t5o?n%v9l)S1zqd94Kpn%|KJ0#sB~Ohjqhjktj&l&QW*v9;; z0RYq-4PtS8a$@o%38SaADVnXqPp1tjKM>G^0UJ5;svDc0j;^Z{dsiGRcv>Wje24)X zuw(+hW

j01CFmN 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 literal 0 HcmV?d00001 diff --git a/packages/docs/pages/build-integrations/actions.md b/packages/docs/pages/build-integrations/actions.md new file mode 100644 index 0000000..17ba406 --- /dev/null +++ b/packages/docs/pages/build-integrations/actions.md @@ -0,0 +1,127 @@ +# Actions + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +## Add actions to the app. + +Open the `thecatapi/index.js` file and add the highlighted lines for actions. + +```javascript{4,17} +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; +import actions from './actions/index.js'; + +export default defineApp({ + name: 'The cat API', + key: 'thecatapi', + iconUrl: '{BASE_URL}/apps/thecatapi/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/thecatapi/connection', + supportsConnections: true, + baseUrl: 'https://thecatapi.com', + apiBaseUrl: 'https://api.thecatapi.com', + primaryColor: '#000000', + auth, + triggers + actions +}); +``` + +## Define actions + +Create the `actions/index.js` file inside of the `thecatapi` folder. + +```javascript +import markCatImageAsFavorite from './mark-cat-image-as-favorite/index.js'; + +export default [markCatImageAsFavorite]; +``` + +:::tip +If you add new actions, you need to add them to the actions/index.js file and export all actions as an array. +::: + +## Add metadata + +Create the `actions/mark-cat-image-as-favorite/index.js` file inside the `thecatapi` folder. + +```javascript +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + name: 'Mark the cat image as favorite', + key: 'markCatImageAsFavorite', + description: 'Marks the cat image as favorite.', + arguments: [ + { + label: 'Image ID', + key: 'imageId', + type: 'string', + required: true, + description: 'The ID of the cat image you want to mark as favorite.', + variables: true, + }, + ], + + async run($) { + // TODO: Implement action! + }, +}); +``` + +Let's briefly explain what we defined here. + +- `name`: The name of the action. +- `key`: The key of the action. This is used to identify the action in Automatisch. +- `description`: The description of the action. +- `arguments`: The arguments of the action. These are the values that the user provides when using the action. +- `run`: The function that is executed when the action is executed. + +## Implement the action + +Open the `actions/mark-cat-image-as-favorite.js` file and add the highlighted lines. + +```javascript{7-20} +import defineAction from '../../../../helpers/define-action.js'; + +export default defineAction({ + // ... + + async run($) { + const requestPath = '/v1/favourites'; + const imageId = $.step.parameters.imageId; + + const headers = { + 'x-api-key': $.auth.data.apiKey, + }; + + const response = await $.http.post( + requestPath, + { image_id: imageId }, + { headers } + ); + + $.setActionItem({ raw: response.data }); + }, +}); +``` + +In this action, we send a request to the cat API to mark the cat image as favorite. We used the `$.http.post` method to send the request. The request body contains the image ID as it's required by the API. + +`$.setActionItem` is used to set the result of the action, so we set the response data as the action item. This is used to display the result of the action in the Automatisch UI and can be used in the next steps of the workflow. + +## Test the action + +Go to the flows page of Automatisch and create a new flow. Add the `Search cat images` as a trigger in the flow. Add the `Mark the cat image as favorite` action to the flow as a second step. Add one of the image IDs you got from the cat API as `Image ID` argument to the action. Click `Test & Continue` button. If you a see JSON response in the user interface, it means that both the trigger and the action we built are working properly. diff --git a/packages/docs/pages/build-integrations/app.md b/packages/docs/pages/build-integrations/app.md new file mode 100644 index 0000000..cc1cfcb --- /dev/null +++ b/packages/docs/pages/build-integrations/app.md @@ -0,0 +1,77 @@ +# App + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +Let's start building our first app by using [TheCatApi](https://thecatapi.com/) service. It's a service that provides cat images and allows you to vote or favorite a specific cat image. It's an excellent example to demonstrate how Automatisch works with an API that has authentication and data fetching with pagination. + +We will build an app with the `Search cat images` trigger and `Mark the cat image as favorite` action. So we will learn how to build both triggers and actions. + +## Define the app + +The first thing we need to do is to create a folder inside of the apps in the backend package. + +```bash +cd packages/backend/src/apps +mkdir thecatapi +``` + +We need to create an `index.js` file inside of the `thecatapi` folder. + +```bash +cd thecatapi +touch index.js +``` + +Then let's define the app inside of the `index.js` file as follows: + +```javascript +import defineApp from '../../helpers/define-app.js'; + +export default defineApp({ + name: 'The cat API', + key: 'thecatapi', + iconUrl: '{BASE_URL}/apps/thecatapi/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/thecatapi/connection', + supportsConnections: true, + baseUrl: 'https://thecatapi.com', + apiBaseUrl: 'https://api.thecatapi.com', + primaryColor: '#000000', +}); +``` + +- `name` is the displayed name of the app in Automatisch. +- `key` is the unique key of the app. It's used to identify the app in Automatisch. +- `iconUrl` is the URL of the app icon. It's used in Automatisch to display the app icon. You can use `{BASE_URL}` placeholder to refer to the base URL of the app. We expect you to place the SVG icon as `assets/favicon.svg` file. +- `authDocUrl` is the URL of the documentation page that describes how to connect to the app. It's used in Automatisch to display the documentation link on the connection page. +- `supportsConnections` is a boolean that indicates whether the app supports connections or not. If it's `true`, Automatisch will display the connection page for the app. Some apps like RSS and Scheduler do not support connections since they do not have authentication. +- `baseUrl` is the base URL of the third-party service. +- `apiBaseUrl` is the API URL of the third-party service. +- `primaryColor` is the primary color of the app. It's used in Automatisch to generate the app icon if it does not provide an icon. You can put any hex color code that reflects the branding of the third-party service. + +## Create the favicon + +Even though we have defined the `iconUrl` inside the app definition, we still need to create the icon file. Let's create the `assets` folder inside the `thecatapi` folder and save [this SVG file](../public/example-app/cat.svg) as `favicon.svg` inside of the `assets` folder. + +:::tip +If you're looking for SVG icons for third-party services, you can use the following repositories. + +- [gilbarbara/logos](https://github.com/gilbarbara/logos) +- [edent/SuperTinyIcons](https://github.com/edent/SuperTinyIcons) + +::: + +## Test the app definition + +Now, you can go to the `My Apps` page on Automatisch and click on `Add connection` button, and then you will see `The cat API` service with the icon. diff --git a/packages/docs/pages/build-integrations/auth.md b/packages/docs/pages/build-integrations/auth.md new file mode 100644 index 0000000..3b65cb5 --- /dev/null +++ b/packages/docs/pages/build-integrations/auth.md @@ -0,0 +1,201 @@ +# Auth + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +## Sign up for the cat API + +Go to the [sign up page](https://thecatapi.com/signup) of the cat API and register your account. It allows you to have 10k requests per month with a free account. You will get an API key by email after the registration. We will use this API key for authentication later on. + +## The cat API docs + +You can find detailed documentation of the cat API [here](https://docs.thecatapi.com). You need to revisit this page while building the integration. + +## Add auth to the app + +Open the `thecatapi/index.js` file and add the highlighted lines for authentication. + +```javascript{2,13} +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; + +export default defineApp({ + name: 'The cat API', + key: 'thecatapi', + iconUrl: '{BASE_URL}/apps/thecatapi/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/thecatapi/connection', + supportsConnections: true, + baseUrl: 'https://thecatapi.com', + apiBaseUrl: 'https://api.thecatapi.com', + primaryColor: '#000000', + auth, +}); +``` + +## Define auth fields + +Let's create the `auth/index.js` file inside of the `thecatapi` folder. + +```bash +mkdir auth +touch auth/index.js +``` + +Then let's start with defining fields the auth inside of the `auth/index.js` file as follows: + +```javascript +export default { + fields: [ + { + key: 'screenName', + label: 'Screen Name', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: + 'Screen name of your connection to be used on Automatisch UI.', + clickToCopy: false, + }, + { + key: 'apiKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'API key of the cat API service.', + clickToCopy: false, + }, + ], +}; +``` + +We have defined two fields for the auth. + +- The `apiKey` field will be used to authenticate the requests to the cat API. +- The `screenName` field will be used to identify the connection on the Automatisch UI. + +:::warning +You have to add a screen name field in case there is no API endpoint where you can get the username or any other information about the user that you can use as a screen name. Some of the APIs have an endpoint for this purpose like `/me` or `/users/me`, but in our example, the cat API doesn't have such an endpoint. +::: + +:::danger +If the third-party service you use provides both an API key and OAuth for the authentication, we expect you to use OAuth instead of an API key. Please consider that when you create a pull request for a new integration. Otherwise, we might ask you to have changes to use OAuth. To see apps with OAuth implementation, you can check [examples](/build-integrations/examples#_3-legged-oauth). +::: + +## Verify credentials + +So until now, we integrated auth folder with the app definition and defined the auth fields. Now we need to verify the credentials that the user entered. We will do that by defining the `verifyCredentials` method. + +Start with adding the `verifyCredentials` method to the `auth/index.js` file. + +```javascript{1,8} +import verifyCredentials from './verify-credentials.js'; + +export default { + fields: [ + // ... + ], + + verifyCredentials, +}; +``` + +Let's create the `verify-credentials.js` file inside the `auth` folder. + +```javascript +const verifyCredentials = async ($) => { + // TODO: Implement verification of the credentials +}; + +export default verifyCredentials; +``` + +We generally use the `users/me` endpoint or any other endpoint that we can validate the API key or any other credentials that the user provides. For our example, we don't have a specific API endpoint to check whether the credentials are correct or not. So we will randomly pick one of the API endpoints, which will be the `GET /v1/images/search` endpoint. We will send a request to this endpoint with the API key. If the API key is correct, we will get a response from the API. If the API key is incorrect, we will get an error response from the API. + +Let's implement the authentication logic that we mentioned above in the `verify-credentials.js` file. + +```javascript +const verifyCredentials = async ($) => { + await $.http.get('/v1/images/search'); + + await $.auth.set({ + screenName: $.auth.data.screenName, + }); +}; + +export default verifyCredentials; +``` + +Here we send a request to the `/v1/images/search` endpoint with the API key. If we get a response from the API, we will set the screen name to the auth data. If we get an error response from the API, it will throw an error. + +:::warning +You must always provide a `screenName` field to auth data in the `verifyCredentials` method. Otherwise, the connection will not have a name and it will not function properly in the user interface. +::: + +## Is still verified? + +We have implemented the `verifyCredentials` method. Now we need to check whether the credentials are still valid or not for the test connection functionality in Automatisch. We will do that by defining the `isStillVerified` method. + +Start with adding the `isStillVerified` method to the `auth/index.js` file. + +```javascript{2,10} +import verifyCredentials from './verify-credentials.js'; +import isStillVerified from './is-still-verified.js'; + +export default { + fields: [ + // ... + ], + + verifyCredentials, + isStillVerified, +}; +``` + +Let's create the `is-still-verified.js` file inside the `auth` folder. + +```javascript +import verifyCredentials from './verify-credentials.js'; + +const isStillVerified = async ($) => { + await verifyCredentials($); + return true; +}; + +export default isStillVerified; +``` + +:::info +`isStillVerified` method needs to return the `truthy` value if the credentials are still valid. +::: + +We will use the `verifyCredentials` method to check whether the credentials are still valid or not. If the credentials are still valid, we will return `true`. Otherwise, it will throw an error which will automatically be handled by Automatisch. + +:::warning +You might be wondering why we need to have two separate functions even though we use only one of them behind the scenes in this scenario. That might be true in our example or any other APIs similar to the cat API but there are some other third-party APIs that we can't use the same functionality directly to check whether the credentials are still valid or not. So we need to have two separate functions for verifying the credentials and checking whether the credentials are still valid or not. +::: + +:::tip +If your integration requires you to connect through the authorization URL of the third-party service, you need to use the `generateAuthUrl` method together with the `verifyCredentials` and the `isStillVerified` methods. Check [3-legged OAuth](/build-integrations/examples#_3-legged-oauth) examples to see how to implement them. +::: + +## Test the authentication + +Now we have completed the authentication of the cat API. Go to the `My Apps` page in Automatisch, try to add a new connection, select `The Cat API` and use the `API Key` you got with an email. Then you can also check the test connection and reconnect functionality there. + +Let's move on to the next page to build a trigger. diff --git a/packages/docs/pages/build-integrations/examples.md b/packages/docs/pages/build-integrations/examples.md new file mode 100644 index 0000000..d8c89cc --- /dev/null +++ b/packages/docs/pages/build-integrations/examples.md @@ -0,0 +1,80 @@ +# Examples + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +## Authentication + +### 3-legged OAuth + +- [Discord](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/discord/auth/index.js) +- [Flickr](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/flickr/auth/index.js) +- [Github](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/github/auth/index.js) +- [Salesforce](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/salesforce/auth/index.js) +- [Slack](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/slack/auth/index.js) +- [Twitter](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twitter/auth/index.js) + +### OAuth with the refresh token + +- [Salesforce](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/salesforce/auth/index.js) + +### API key + +- [DeepL](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/deepl/auth/index.js) +- [Twilio](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twilio/auth/index.js) +- [SignalWire](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/signalwire/auth/index.js) +- [SMTP](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/smtp/auth/index.js) + +### Without authentication + +- [RSS](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/rss/index.js) +- [Scheduler](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/scheduler/index.js) + +## Triggers + +### Polling-based triggers + +- [Search tweets - Twitter](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twitter/triggers/search-tweets/index.js) +- [New issues - Github](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/github/triggers/new-issues/index.js) + +### Webhook-based triggers + +:::warning +If you are developing a webhook-based trigger, you need to ensure that the webhook is publicly accessible. You can use [ngrok](https://ngrok.com) for this purpose and override the webhook URL by setting the **WEBHOOK_URL** environment variable. +::: + +- [New entry - Typeform](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/typeform/triggers/new-entry/index.js) + +### Pagination with descending order + +- [Search tweets - Twitter](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twitter/triggers/search-tweets/index.js) +- [New issues - Github](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/github/triggers/new-issues/index.js) +- [Receive SMS - Twilio](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twilio/triggers/receive-sms/index.js) +- [Receive SMS - SignalWire](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/signalwire/triggers/receive-sms/index.js) +- [New photos - Flickr](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/flickr/triggers/new-photos/index.js) + +### Pagination with ascending order + +- [New stargazers - Github](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/github/triggers/new-stargazers/index.js) +- [New watchers - Github](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/github/triggers/new-watchers/index.js) + +## Actions + +- [Send a message to channel - Slack](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.js) +- [Send SMS - Twilio](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twilio/actions/send-sms/index.js) +- [Send a message to channel - Discord](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/discord/actions/send-message-to-channel/index.js) +- [Create issue - Github](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/github/actions/create-issue/index.js) +- [Send an email - SMTP](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/smtp/actions/send-email/index.js) +- [Create tweet - Twitter](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/twitter/actions/create-tweet/index.js) +- [Translate text - DeepL](https://github.com/automatisch/automatisch/tree/main/packages/backend/src/apps/deepl/actions/translate-text/index.js) diff --git a/packages/docs/pages/build-integrations/folder-structure.md b/packages/docs/pages/build-integrations/folder-structure.md new file mode 100644 index 0000000..bfaa49e --- /dev/null +++ b/packages/docs/pages/build-integrations/folder-structure.md @@ -0,0 +1,68 @@ +# Folder Structure + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +:::warning +If you still need to set up the development environment, please go back to the [development setup](/contributing/development-setup) page and follow the instructions. +::: + +:::tip +We will use the terms **integration** and **app** interchangeably in the documentation. +::: + +Before diving into how to build an integration for Automatisch, it's better to check the folder structure of the apps and give you some idea about how we place different parts of the app. + +## Folder structure of an app + +Here, you can see the folder structure of an example app. We will briefly walk through the folders, explain what they are used for, and dive into the details in the following pages. + +``` +. +├── actions +├── assets +├── auth +├── common +├── dynamic-data +├── index.js +└── triggers +``` + +## App + +The `index.js` file is the entry point of the app. It contains the definition of the app and the app's metadata. It also includes the list of triggers, actions, and data sources that the app provides. So, whatever you build inside the app, you need to associate it within the `index.js` file. + +## Auth + +We ask users to authenticate with their third-party service accounts (we also document how they can accomplish this for each app.), and we store the encrypted credentials in our database. Later on, we use the credentials to make requests to the third-party service when we use them within triggers and actions. Auth folder is responsible for getting those credentials and saving them as connections for later use. + +## Triggers + +Triggers are the starting points of the flows. The first step in the flow always has to be a trigger. Triggers are responsible for fetching data from the third-party service and sending it to the next steps of the flow, which are actions. + +## Actions + +As mentioned above, actions are the steps we place after a trigger. Actions are responsible for getting data from their previous steps and taking action with that data. For example, when a new issue is created in GitHub, which is working with a trigger, we can send a message to the Slack channel, which will happen with an action from the Slack application. + +## Common + +The common folder is where you can put utilities or shared functionality used by other folders like triggers, actions, auth, etc. + +## Dynamic data + +Sometimes you need to get some dynamic data with the user interface to set up the triggers or actions. For example, to use the new issues trigger from the GitHub app, we need to select the repository we want to track for the new issues. This selection should load the repository list from GitHub. This is where the data folder comes into play. You can put your data fetching logic here when it doesn't belong to triggers or actions but is used to set up triggers or actions in the Automatisch user interface. + +## Assets + +It is the folder we designed to put the app's static files, but currently we support serving only the `favicon.svg` file from the folder. diff --git a/packages/docs/pages/build-integrations/global-variable.md b/packages/docs/pages/build-integrations/global-variable.md new file mode 100644 index 0000000..df0e564 --- /dev/null +++ b/packages/docs/pages/build-integrations/global-variable.md @@ -0,0 +1,106 @@ +# Global Variable + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +Before handling authentication and building a trigger and an action, it's better to explain the `global variable` concept in Automatisch. Automatisch provides you the global variable that you need to use with authentication, triggers, actions, and basically all the stuff you will build for the integration. + +The global variable is represented as `$` variable in the codebase, and it's a JS object that contains the following properties: + +## $.auth.set + +```javascript +await $.auth.set({ + key: 'value', +}); +``` + +It's used to set the authentication data, and you can use this method with multiple pairs. The data will be stored in the database and can be retrieved later by using `$.auth.data` property. The data you set with this method will not override its current value but expands it. We use this method when we store the credentials of the third-party service. Note that Automatisch encrypts the data before storing it in the database. + +## $.auth.data + +```javascript +$.auth.data; // { key: 'value' } +``` + +It's used to retrieve the authentication data that we set with `$.auth.set()`. The data will be retrieved from the database. We use the data property with the key name when we need to get one specific value from the data object. + +## $.app.baseUrl + +```javascript +$.app.baseUrl; // https://thecatapi.com +``` + +It's used to retrieve the base URL of the app that we defined previously. In our example, it returns `https://thecatapi.com`. We use this property when we need to use the base URL of the third-party service. + +## $.app.apiBaseUrl + +```javascript +$.app.apiBaseUrl; // https://api.thecatapi.com +``` + +It's used to retrieve the API base URL of the app that we defined previously. In our example, it returns `https://api.thecatapi.com`. We use this property when we need to use the API base URL of the third-party service. + +## $.app.auth.fields + +```javascript +$.app.auth.fields; +``` + +It's used to retrieve the fields that we defined in the `auth` section of the app. We use this property when we need to get the fields of the authentication section of the app. + +## $.http + +It's an HTTP client to be used for making HTTP requests. It's a wrapper around the [axios](https://axios-http.com) library. We use this property when we need to make HTTP requests to the third-party service. The `apiBaseUrl` field we set up in the app will be used as the base URL for the HTTP requests. For example, to search the cat images, we can use the following code: + +```javascript +await $.http.get('/v1/images/search?order=DESC', { + headers: { + 'x-api-key': $.auth.data.apiKey, + }, +}); +``` + +Keep in mind that the HTTP client handles the error with the status code that falls out of the range of 2xx. So, you don't need to handle the error manually. It will be processed with the error message or error payload that you can check on the execution details page in Automatisch. + +## $.step.parameters + +```javascript +$.step.parameters; // { key: 'value' } +``` + +It refers to the parameters that are set by users in the UI. We use this property when we need to get the parameters for corresponding triggers and actions. For example [Send a message to channel](https://github.com/automatisch/automatisch/blob/main/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.js) action from Slack integration, we have a step parameter called `message` that we need to use in the action. We can use `$.step.parameters.message` to get the value of the message to send a message to the Slack channel. + +## $.pushTriggerItem + +```javascript +$.pushTriggerItem({ + raw: resourceData, + meta: { + id: resourceData.id, + }, +}); +``` + +It's used to push trigger data to be processed by Automatisch. It must reflect the data that we get from the third-party service. Let's say for search tweets trigger the `resourceData` will be the JSON that represents the single tweet object. + +## $.setActionItem + +```javascript +$.setActionItem({ + raw: resourceData, +}); +``` + +It's used to set the action data to be processed by Automatisch. For actions, it reflects the response data that we get from the third-party service. Let's say for create tweet action it will be the JSON that represents the response payload we get while creating a tweet. diff --git a/packages/docs/pages/build-integrations/triggers.md b/packages/docs/pages/build-integrations/triggers.md new file mode 100644 index 0000000..1583cf9 --- /dev/null +++ b/packages/docs/pages/build-integrations/triggers.md @@ -0,0 +1,157 @@ +# Triggers + +:::info + +The build integrations section is best understood when read from beginning to end. To get the most value out of it, start from the first page and read through page by page. + +1. [Folder structure](/build-integrations/folder-structure) +2. [App](/build-integrations/app) +3. [Global variable](/build-integrations/global-variable) +4. [Auth](/build-integrations/auth) +5. [Triggers](/build-integrations/triggers) +6. [Actions](/build-integrations/actions) +7. [Examples](/build-integrations/examples) + +::: + +:::warning +We used a polling-based HTTP trigger in our example but if you need to use a webhook-based trigger, you can check the [examples](/build-integrations/examples#webhook-based-triggers) page. +::: + +## Add triggers to the app + +Open the `thecatapi/index.js` file and add the highlighted lines for triggers. + +```javascript{3,15} +import defineApp from '../../helpers/define-app.js'; +import auth from './auth/index.js'; +import triggers from './triggers/index.js'; + +export default defineApp({ + name: 'The cat API', + key: 'thecatapi', + iconUrl: '{BASE_URL}/apps/thecatapi/assets/favicon.svg', + authDocUrl: '{DOCS_URL}/apps/thecatapi/connection', + supportsConnections: true, + baseUrl: 'https://thecatapi.com', + apiBaseUrl: 'https://api.thecatapi.com', + primaryColor: '#000000', + auth, + triggers +}); +``` + +## Define triggers + +Create the `triggers/index.js` file inside of the `thecatapi` folder. + +```javascript +import searchCatImages from './search-cat-images/index.js'; + +export default [searchCatImages]; +``` + +:::tip +If you add new triggers, you need to add them to the `triggers/index.js` file and export all triggers as an array. The order of triggers in this array will be reflected in the Automatisch user interface. +::: + +## Add metadata + +Create the `triggers/search-cat-images/index.js` file inside of the `thecatapi` folder. + +```javascript +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + name: 'Search cat images', + key: 'searchCatImages', + pollInterval: 15, + description: 'Triggers when there is a new cat image.', + + async run($) { + // TODO: Implement trigger! + }, +}); +``` + +Let's briefly explain what we defined here. + +- `name`: The name of the trigger. +- `key`: The key of the trigger. This is used to identify the trigger in Automatisch. +- `pollInterval`: The interval in minutes in which the trigger should be polled. Even though we allow to define `pollInterval` field, it's not used in Automatisch at the moment. Currently, the default is 15 minutes and it's not possible to change it. +- `description`: The description of the trigger. +- `run`: The function that is executed when the trigger is triggered. + +## Implement the trigger + +:::danger + +- Automatisch expects you to push data in **reverse-chronological order** otherwise, the trigger will not work properly. +- You have to push the `unique identifier` (it can be IDs or any field that can be used to identify the data) as `internalId`. This is used to prevent duplicate data. + +::: + +Implement the `run` function by adding highlighted lines. + +```javascript{1,7-30} +import defineTrigger from '../../../../helpers/define-trigger.js'; + +export default defineTrigger({ + // ... + async run($) { + let page = 0; + let response; + + const headers = { + 'x-api-key': $.auth.data.apiKey, + }; + + do { + let requestPath = `/v1/images/search?page=${page}&limit=10&order=DESC`; + response = await $.http.get(requestPath, { headers }); + + response.data.forEach((image) => { + const dataItem = { + raw: image, + meta: { + internalId: image.id + }, + }; + + $.pushTriggerItem(dataItem); + }); + + page += 1; + } while (response.data.length >= 10); + }, +}); +``` + +We are using the `$.http` object to make HTTP requests. Our API is paginated, so we are making requests until we get less than 10 items, which means the last page. + +We do not have to return anything from the `run` function. We are using the `$.pushTriggerItem` function to push the data to Automatisch. $.pushTriggerItem accepts an object with the following fields: + +- `raw`: The raw data that you want to push to Automatisch. +- `meta`: The metadata of the data. It has to have the `internalId` field. + +:::tip + +`$.pushTriggerItem` is smart enough to understand if the data is already pushed to Automatisch or not. If the data is already pushed and processed, it will stop the trigger, otherwise, it will continue to fetch new data. The check is done by comparing the `internalId` field with the `internalId` field of the data that is already processed. The control of whether the data is already processed or not is scoped by the flow. + +::: + +:::tip + +`$.pushTriggerItem` also understands whether the trigger is executed with `Test & Continue` button in the user interface or it's a trigger from a published flow. If the trigger is executed with `Test & Continue` button, it will push only one item regardless of whether we already processed the data or not and early exits the process, otherwise, it will fetch the remaining data. + +::: + +:::tip + +Let's say the trigger started to execute. It fetched the first five pages of data from the third-party API with five different HTTP requests and you still need to get the next page but you started to get an API rate limit error. In this case, Automatisch will not lose the data that is already fetched from the first five requests. It stops the trigger when it got the error the first time but processes all previously fetched data. + +::: + +## Test the trigger + +Go to the flows page of Automatisch and create a new flow. Choose `The cat API` app and the `Search cat images` trigger and click `Test & Continue` button. If you a see JSON response in the user interface, it means that the trigger is working properly. diff --git a/packages/docs/pages/components/CustomListing.vue b/packages/docs/pages/components/CustomListing.vue new file mode 100644 index 0000000..d9bdca1 --- /dev/null +++ b/packages/docs/pages/components/CustomListing.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/docs/pages/contributing/contribution-guide.md b/packages/docs/pages/contributing/contribution-guide.md new file mode 100644 index 0000000..dd84098 --- /dev/null +++ b/packages/docs/pages/contributing/contribution-guide.md @@ -0,0 +1,25 @@ +# Contribution Guide + +We are happy that you want to contribute to Automatisch. We will assist you in the contribution process. This guide will help you to get started. + +## We develop with GitHub + +We use GitHub to host code, track issues, and feature requests, as well as accept pull requests. You can follow those steps to contribute to the project: + +1. Fork the repository and create your branch from the `main`. +2. Create your feature branch (`git checkout -b feature/feature-description`) +3. If you've added code that should be documented, update the documentation. +4. Make sure to use the linter by running `yarn lint` command in the project root folder. +5. Create a pull request! + +## Use conventional commit messages + +We use [conventional commit messages](https://www.conventionalcommits.org) to generate changelogs and release notes. Therefore, please follow the guidelines when writing commit messages. + +## Report bugs using GitHub issues + +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/automatisch/automatisch/issues/new). + +## License + +By contributing, you agree that your contributions will be licensed under the AGPL-3.0 license. diff --git a/packages/docs/pages/contributing/development-setup.md b/packages/docs/pages/contributing/development-setup.md new file mode 100644 index 0000000..dbc4aaf --- /dev/null +++ b/packages/docs/pages/contributing/development-setup.md @@ -0,0 +1,101 @@ +# Development Setup + +Clone main branch of Automatisch. + +```bash +git clone git@github.com:automatisch/automatisch.git +``` + +Then, install the dependencies for both backend and web packages separately. + +```bash +cd automatisch + +# Install backend dependencies +cd packages/backend +yarn install + +# Install web dependencies +cd packages/web +yarn install + +``` + +## Backend + +Make sure that you have **PostgreSQL** and **Redis** installed and running. + +:::warning +Scripts we have prepared for Automatisch work with PostgreSQL version 14. If you have a different version, you might have some problems with the database setup. +::: + +Create a `.env` file in the backend package: + +```bash +cd packages/backend +cp .env-example .env +``` + +Create the development database in the backend folder. + +```bash +yarn db:create +``` + +:::warning +`yarn db:create` commands expect that you have the `postgres` superuser. If not, you can create a superuser called `postgres` manually or you can create the database manually by checking PostgreSQL-related default values from the [app config](https://github.com/automatisch/automatisch/blob/main/packages/backend/src/config/app.js). +::: + +Run the database migrations in the backend folder. + +```bash +yarn db:migrate +``` + +Create a seed user with `user@automatisch.io` email and `sample` password. + +```bash +yarn db:seed:user +``` + +Start the main backend server. + +```bash +cd packages/backend +yarn dev +``` + +Start the worker server in another terminal tab. + +```bash +cd packages/backend +yarn worker +``` + +## Frontend + +Create a `.env` file in the web package: + +```bash +cd packages/web +cp .env-example .env +``` + +Start the frontend server in another terminal tab. + +```bash +cd packages/web +yarn dev +``` + +It will automatically open [http://localhost:3001](http://localhost:3001) in your browser. Then, use the `user@automatisch.io` email address and `sample` password to login. + +## Docs server + +```bash +cd packages/docs +yarn install +yarn dev +``` + +You can check the docs server via [http://localhost:3002](http://localhost:3002). diff --git a/packages/docs/pages/contributing/repository-structure.md b/packages/docs/pages/contributing/repository-structure.md new file mode 100644 index 0000000..f8b613c --- /dev/null +++ b/packages/docs/pages/contributing/repository-structure.md @@ -0,0 +1,19 @@ +# Repository Structure + +We manage a monorepo structure with the following packages: + +``` +. +├── packages +│   ├── backend +│   ├── docs +│   ├── e2e-tests +│   └── web +``` + +- `backend` - The backend package contains the backend application and all integrations. +- `docs` - The docs package contains the documentation website. +- `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage. +- `web` - The web package contains the frontend application of Automatisch. + +Each package is independently managed, and has its own package.json file to manage dependencies. This allows for better isolation and flexibility. diff --git a/packages/docs/pages/guide/available-apps.md b/packages/docs/pages/guide/available-apps.md new file mode 100644 index 0000000..81cb3c0 --- /dev/null +++ b/packages/docs/pages/guide/available-apps.md @@ -0,0 +1,74 @@ +# Available Apps + +The following integrations are currently supported by Automatisch. + +- [Airtable](/apps/airtable/actions) +- [Anthropic](/apps/anthropic/actions) +- [Appwrite](/apps/appwrite/triggers) +- [Brave Search](/apps/brave-search/actions) +- [Carbone](/apps/carbone/actions) +- [ClickUp](/apps/clickup/triggers) +- [Cryptography](/apps/cryptography/actions) +- [Datastore](/apps/datastore/actions) +- [DeepL](/apps/deepl/actions) +- [Delay](/apps/delay/actions) +- [Discord](/apps/discord/actions) +- [Disqus](/apps/disqus/triggers) +- [Dropbox](/apps/dropbox/actions) +- [Filter](/apps/filter/actions) +- [Flickr](/apps/flickr/triggers) +- [Formatter](/apps/formatter/actions) +- [FreeScout](/apps/freescout/triggers) +- [Ghost](/apps/ghost/triggers) +- [GitHub](/apps/github/triggers) +- [GitLab](/apps/gitlab/triggers) +- [Google Calendar](/apps/google-calendar/triggers) +- [Google Drive](/apps/google-drive/triggers) +- [Google Forms](/apps/google-forms/triggers) +- [Google Sheets](/apps/google-sheets/triggers) +- [Google Tasks](/apps/google-tasks/actions) +- [HTTP Request](/apps/http-request/actions) +- [HubSpot](/apps/hubspot/actions) +- [Invoice Ninja](/apps/invoice-ninja/triggers) +- [Jotform](/apps/jotform/triggers) +- [Mailchimp](/apps/mailchimp/triggers) +- [MailerLite](/apps/mailerlite/triggers) +- [Mattermost](/apps/mattermost/actions) +- [Miro](/apps/miro/actions) +- [Mistral AI](/apps/mistral-ai/actions) +- [Notion](/apps/notion/triggers) +- [Ntfy](/apps/ntfy/actions) +- [Odoo](/apps/odoo/actions) +- [OpenAI](/apps/openai/actions) +- [OpenRouter](/apps/openrouter/actions) +- [Perplexity](/apps/perplexity/actions) +- [Pipedrive](/apps/pipedrive/triggers) +- [Placetel](/apps/placetel/triggers) +- [PostgreSQL](/apps/postgresql/actions) +- [Pushover](/apps/pushover/actions) +- [Reddit](/apps/reddit/triggers) +- [Remove.bg](/apps/removebg/actions) +- [RSS](/apps/rss/triggers) +- [Salesforce](/apps/salesforce/triggers) +- [Scheduler](/apps/scheduler/triggers) +- [SignalWire](/apps/signalwire/triggers) +- [Slack](/apps/slack/actions) +- [SMTP](/apps/smtp/actions) +- [Spotify](/apps/spotify/actions) +- [Strava](/apps/strava/actions) +- [Stripe](/apps/stripe/triggers) +- [Telegram](/apps/telegram-bot/actions) +- [Todoist](/apps/todoist/triggers) +- [Together AI](/apps/together-ai/actions) +- [Trello](/apps/trello/actions) +- [Twilio](/apps/twilio/triggers) +- [Twitter](/apps/twitter/triggers) +- [Typeform](/apps/typeform/triggers) +- [VirtualQ](/apps/virtualq/actions) +- [Vtiger CRM](/apps/vtiger-crm/triggers) +- [Webhooks](/apps/webhooks/triggers) +- [WordPress](/apps/wordpress/triggers) +- [Xero](/apps/xero/triggers) +- [You Need A Budget](/apps/you-need-a-budget/triggers) +- [Youtube](/apps/youtube/triggers) +- [Zendesk](/apps/zendesk/actions) diff --git a/packages/docs/pages/guide/create-flow.md b/packages/docs/pages/guide/create-flow.md new file mode 100644 index 0000000..4f7e849 --- /dev/null +++ b/packages/docs/pages/guide/create-flow.md @@ -0,0 +1,53 @@ +# Create Flow + +To understand how we can create a flow, it's better to start with a real use case. Let's say we want to create a flow that will fetch new submissions from Typeform and then send them to a Slack channel. To do that, we will use [Typeform](/apps/typeform/triggers) and [Slack](/apps/slack/actions) apps. Let's start with creating connections for these apps. + +## Typeform connection + +- Go to the **My Apps** page in Automatisch and click on **Add connection** button. +- Select the **Typeform** app from the list. +- It will ask you `Client ID` and `Client Secret` from Typeform and there is an information box above the fields. +- Click on **our documentation** link in the information box and follow the instructions to get the `Client ID` and `Client Secret` from Typeform. + +:::tip +Whenever you want to create a connection for an app, you can click on **our documentation** link in the information box to learn how to create a connection for that specific app. +::: + +- After you get the `Client ID` and `Client Secret` from Typeform, you can paste them to the fields in Automatisch and click on **Submit** button. + +## Slack connection + +- Go to the **My Apps** page in Automatisch and click on **Add connection** button. +- Select the **Slack** app from the list. +- It will ask you `API Key` and `API Secret` values from Slack and there is an information box above the fields. +- Click on **our documentation** link in the information box and follow the instructions to get the `API Key` and `API Secret` from Slack. +- After you get the `API Key` and `API Secret` from Slack, you can paste them into the fields in Automatisch and click on **Submit** button. + +## Build the flow + +### Trigger step + +- Go to the **Flows** page in Automatisch and click on **Create flow** button. +- It will give you empty trigger and action steps. +- For the trigger step (1st step), select the **Typeform** app from `Choose an app` dropdown. +- Select the **New entry** as the trigger event and click on the **Continue** button. +- It will ask you to select the connection you created for the Typeform app. Select the connection you have just created and click on the **Continue** button. +- Select the form you want to get the new entries from and click on the **Continue** button. +- Click on **Test & Continue** button to test the trigger step. If you see the data that reflects the recent submission in the form, you can continue to the next (action) step. + +### Action step + +- For the action step (2nd step), select the **Slack** app from `Choose an app` dropdown. +- Select the **Send a message to channel** as the action event and click on the **Continue** button. +- It will ask you to select the connection you created for the Slack app. Select the connection you have just created and click on the **Continue** button. +- Select the channel you want to send the message to. +- Write the message you want to send to the channel. You can use variables in the message from the trigger step. +- Select `Yes` for the `Send as a bot` option. +- Give a name for the bot and click on the **Continue** button. +- Click on **Test & Continue** button to test the action step. If you see the message in the Slack channel you selected, we can say that the flow is working as expected and is ready to be published. + +### Publish the flow + +- Click on the **Publish** button to publish the flow. +- Published flows will be executed automatically when the trigger event happens or at intervals of 15 minutes depending on the trigger type. +- You can not change the flow after it's published. If you want to change the flow, you need to unpublish it first and then make the changes. diff --git a/packages/docs/pages/guide/installation.md b/packages/docs/pages/guide/installation.md new file mode 100644 index 0000000..a61d5e0 --- /dev/null +++ b/packages/docs/pages/guide/installation.md @@ -0,0 +1,113 @@ +# Installation + +:::info +We have installation guides for docker compose and docker setup at the moment, but if you need another installation type, let us know by [creating a GitHub issue](https://github.com/automatisch/automatisch/issues/new). +::: + +:::tip + +You can use `user@automatisch.io` email address and `sample` password to login to Automatisch. Please do not forget to change your email and password from the settings page. + +::: + +:::danger +Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment variables. They are used to encrypt your credentials from third-party services and verify webhook requests. If you change them, your existing connections and flows will not continue to work. +::: + +## Docker Compose + +```bash +# Clone the repository +git clone git@github.com:automatisch/automatisch.git + +# Go to the repository folder +cd automatisch + +# Start +docker compose up +``` + +✌️ That's it; you have Automatisch running. Let's check it out by browsing [http://localhost:3000](https://localhost:3000) + +### Upgrade with Docker Compose + +If you want to upgrade the Automatisch version with docker compose, first you need to pull the main branch of Automatisch repository. + +```bash +git pull origin main +``` + +Then you can run the following command to rebuild the containers with the new images. + +```bash +docker compose up --force-recreate --build +``` + +## Docker + +Automatisch comes with two services which are `main` and `worker`. They both use the same image and need to have the same environment variables except for the `WORKER` environment variable which is set to `true` for the worker service. + +::: warning +We give the sample environment variable files for the setup but you should adjust them to include your own values. +::: + +To run the main: + +```bash +docker run --env-file=./.env automatischio/automatisch +``` + +To run the worker: + +```bash +docker run --env-file=./.env -e WORKER=true automatischio/automatisch +``` + +::: details .env + +```bash +APP_ENV=production +HOST= +PROTOCOL= +PORT= +ENCRYPTION_KEY= +WEBHOOK_SECRET_KEY= +APP_SECRET_KEY= +POSTGRES_HOST= +POSTGRES_PORT= +POSTGRES_DATABASE= +POSTGRES_USERNAME= +POSTGRES_PASSWORD= +POSTGRES_ENABLE_SSL= +REDIS_HOST= +REDIS_PORT= +REDIS_USERNAME= +REDIS_PASSWORD= +REDIS_TLS= +``` + +::: + +::: info +You can use the `openssl rand -base64 36` command in your terminal to generate a random string for the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment variables. +::: + +## Render + + + Deploy to Render + + +:::info + +We use default values of render plans with the `render.yaml` file, if you want to use the free plan or change the plan, you can change the `render.yaml` file in your fork and use your repository URL while creating a blueprint in Render. + +::: + +## Production setup + +If you need to change any other environment variables for your production setup, let's check out the [environment variables](/advanced/configuration#environment-variables) section of the configuration page. + +## Let's discover! + +If you see any problems while installing Automatisch, let us know via [github issues](https://github.com/automatisch/automatisch/issues) or our [discord server](https://discord.gg/dJSah9CVrC). diff --git a/packages/docs/pages/guide/key-concepts.md b/packages/docs/pages/guide/key-concepts.md new file mode 100644 index 0000000..41b63b1 --- /dev/null +++ b/packages/docs/pages/guide/key-concepts.md @@ -0,0 +1,28 @@ +# Key Concepts + +We will cover four main terms of Automatisch before creating our first flow. + +## App + +👉 Apps are the third-party services you can use with Automatisch, like Twitter, Github and Slack. You can check the complete list of available apps [here](/guide/available-apps). Automatisch aims to connect those apps to help you build workflows. So whenever you work with other concepts of Automatisch, you will use apps. + +:::tip + +You can request a new integration [here](/guide/request-integration). We will collect all the requests and prioritize the most requested ones. + +::: + +## Connection + +📪 To use an app, you need to add a connection first. Connection is essentially the place where you pass the credentials of the specified service, like consumer key, consumer secret, etc., to let Automatisch connect third-party apps on your behalf. When you click "Add connection" and choose an app, you'll be prompted for the required fields for the connection. You can also add multiple connections if you have more than one account for the same app. + +## Flow + +🛠️ Flow is the most crucial part of Automatisch. It's a place to arrange the business workflow by connecting multiple steps. So, for example, we can define a flow that does: + +- **Search tweets** for the "Automatisch" keyword. +- **Send a message to channel** which posts found tweets to the specified Slack channel. + +## Step + +📄 Steps are the individual items in the flow. In our example, **searching tweets** and **sending a message to channel** are both steps in our flow. Steps have two different types, which are trigger and action. Trigger steps are the ones that start any flow you would like to build with Automatisch, like "search tweets". You can think them as starting points. Action steps are the following steps that define what you would do with the incoming data from previous steps, like "sending a message to channel" in our example. Flows can also have more than two steps. The first step of each flow should be the trigger step, and the following steps should be action steps. diff --git a/packages/docs/pages/guide/request-integration.md b/packages/docs/pages/guide/request-integration.md new file mode 100644 index 0000000..263cb83 --- /dev/null +++ b/packages/docs/pages/guide/request-integration.md @@ -0,0 +1,15 @@ +# Request Integration + +You can request a new integration by using [Github issues](https://github.com/automatisch/automatisch/issues). + +:::info + +While we are working hard to add as many integrations as possible, it might take some time to see your request is being done. It's because we prefer to collect all integration requests and prioritize the most requested ones. + +::: + +:::tip + +If there is already an integration request for the service you'd like, it's still crucial to upvote or comment on that issue for us to analyze the potential audience around the integration. + +::: diff --git a/packages/docs/pages/index.md b/packages/docs/pages/index.md new file mode 100644 index 0000000..864a34f --- /dev/null +++ b/packages/docs/pages/index.md @@ -0,0 +1,43 @@ + + +# What is Automatisch? + +:::warning +Automatisch is still in the early phase of development. We try our best not to introduce breaking changes, but be cautious until v1 is released. +::: + +![Automatisch Flow Page](./assets/flow-900.png) + +🧐 Automatisch is a **business automation** tool that lets you connect different services like Twitter, Slack, and **[more](/guide/available-apps)** to automate your business processes. + +💸 Automating your workflows doesn't have to be a difficult or expensive process. You also **don't need** any programming knowledge to use Automatisch. + +## How it works? + +Automatisch is a software designed to help streamline your workflows by integrating the different services you use. This way, you can avoid spending extra time and money on building integrations or hiring someone to do it for you. + +For example, you can create a workflow for your team by specifying two steps: "search all tweets that include the `Automatisch` keyword" and "post those tweets into a slack channel specified." It is one of the internal workflows we use to test our product. This example only includes Twitter and Slack services, but many more possibilities exist. You can check the list of integrations [here](/guide/available-apps). + +You need to prepare the workflow once, and it will run continuously until you stop it or the connected account gets unlinked. Currently, workflows run at intervals of 15 minutes, but we're planning to extend this behavior and support instant updates if it's available with the third-party service. + +## Advantages + +There are other existing solutions in the market, like Zapier and Integromat, so you might be wondering why you should use Automatisch. + +✅ One of the main benefits of using Automatisch is that it allows you to **store your data on your own servers**, which is essential for businesses that handle sensitive user information and cannot risk sharing it with external cloud services. This is especially relevant for industries such as healthcare and finance, as well as for European companies that must adhere to the General Data Protection Regulation (GDPR). + +🤓 Your contributions are vital to the development of Automatisch. As an **open-source software**, anyone can have an impact on how it is being developed. + +💙 **No vendor lock-in**. If you ever decide that Automatisch is no longer helpful for your business, you can switch to any other provider, which will be easier than switching from the one cloud provider to another since you have all data and flexibility. + +## Let's start! + +Visit our [installation guide](/guide/installation) to setup Automatisch. It's recommended to read through all the getting started sections in the sidebar and [create your first flow](/guide/create-flow). + +## Something missing? + +If you find issues with the documentation or have suggestions on how to improve the documentation or the project in general, please [file an issue](https://github.com/automatisch/automatisch/issues) for us, or send a tweet mentioning the [@automatischio](https://twitter.com/automatischio) Twitter account. diff --git a/packages/docs/pages/other/community.md b/packages/docs/pages/other/community.md new file mode 100644 index 0000000..b6c8529 --- /dev/null +++ b/packages/docs/pages/other/community.md @@ -0,0 +1,7 @@ +# Community + +We believe in the power of open source and the community that surrounds it. We're constantly amazed by the creativity and collaboration that takes place in the open source world, and we're proud to be a part of it. We believe that open source is the future of software development, and we're committed to helping make that future a reality. If you would like to join our community, you can use the following channels to collaborate and have an impact on how we build Automatisch. + +- [Github](https://github.com/automatisch/automatisch) +- [Discord](https://discord.gg/dJSah9CVrC) +- [Twitter](https://twitter.com/automatischio) diff --git a/packages/docs/pages/other/license.md b/packages/docs/pages/other/license.md new file mode 100644 index 0000000..acb385e --- /dev/null +++ b/packages/docs/pages/other/license.md @@ -0,0 +1,11 @@ +# License + +Automatisch Community Edition (Automatisch CE) is an open-source software with the [AGPL-3.0 license](https://github.com/automatisch/automatisch/blob/main/LICENSE.agpl). + +Automatisch Enterprise Edition (Automatisch EE) is a commercial offering with the [Enterprise license](https://github.com/automatisch/automatisch/blob/main/LICENSE.enterprise). + +The Automatisch repository contains both AGPL-licensed and Enterprise-licensed files. We maintain a single repository to make development easier. + +All files that contain ".ee." in their name fall under the [Enterprise license](https://github.com/automatisch/automatisch/blob/main/LICENSE.enterprise). All other files fall under the [AGPL-3.0 license](https://github.com/automatisch/automatisch/blob/main/LICENSE.agpl). + +See the [LICENSE](https://github.com/automatisch/automatisch/blob/main/LICENSE) file for more information. diff --git a/packages/docs/pages/public/example-app/cat.svg b/packages/docs/pages/public/example-app/cat.svg new file mode 100644 index 0000000..b44cf96 --- /dev/null +++ b/packages/docs/pages/public/example-app/cat.svg @@ -0,0 +1,34 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/airtable.svg b/packages/docs/pages/public/favicons/airtable.svg new file mode 100644 index 0000000..867c3b5 --- /dev/null +++ b/packages/docs/pages/public/favicons/airtable.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/anthropic.svg b/packages/docs/pages/public/favicons/anthropic.svg new file mode 100644 index 0000000..affdade --- /dev/null +++ b/packages/docs/pages/public/favicons/anthropic.svg @@ -0,0 +1,8 @@ + + + Anthropic + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/appwrite.svg b/packages/docs/pages/public/favicons/appwrite.svg new file mode 100644 index 0000000..63bf0f2 --- /dev/null +++ b/packages/docs/pages/public/favicons/appwrite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/brave-search.svg b/packages/docs/pages/public/favicons/brave-search.svg new file mode 100644 index 0000000..8f95398 --- /dev/null +++ b/packages/docs/pages/public/favicons/brave-search.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/carbone.svg b/packages/docs/pages/public/favicons/carbone.svg new file mode 100644 index 0000000..cadf2c9 --- /dev/null +++ b/packages/docs/pages/public/favicons/carbone.svg @@ -0,0 +1,444 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/clickup.svg b/packages/docs/pages/public/favicons/clickup.svg new file mode 100644 index 0000000..9894326 --- /dev/null +++ b/packages/docs/pages/public/favicons/clickup.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/cryptography.svg b/packages/docs/pages/public/favicons/cryptography.svg new file mode 100644 index 0000000..da52932 --- /dev/null +++ b/packages/docs/pages/public/favicons/cryptography.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/docs/pages/public/favicons/datastore.svg b/packages/docs/pages/public/favicons/datastore.svg new file mode 100644 index 0000000..c45032f --- /dev/null +++ b/packages/docs/pages/public/favicons/datastore.svg @@ -0,0 +1,13 @@ + + + + + + datastore + + + + + + + diff --git a/packages/docs/pages/public/favicons/deepl.svg b/packages/docs/pages/public/favicons/deepl.svg new file mode 100644 index 0000000..7b96b43 --- /dev/null +++ b/packages/docs/pages/public/favicons/deepl.svg @@ -0,0 +1,39 @@ + + image/svg+xml + + + + + + + background + + + + Layer 1 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/delay.svg b/packages/docs/pages/public/favicons/delay.svg new file mode 100644 index 0000000..af5da4d --- /dev/null +++ b/packages/docs/pages/public/favicons/delay.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/docs/pages/public/favicons/discord.svg b/packages/docs/pages/public/favicons/discord.svg new file mode 100644 index 0000000..307c770 --- /dev/null +++ b/packages/docs/pages/public/favicons/discord.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/docs/pages/public/favicons/disqus.svg b/packages/docs/pages/public/favicons/disqus.svg new file mode 100644 index 0000000..66ffeb3 --- /dev/null +++ b/packages/docs/pages/public/favicons/disqus.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/dropbox.svg b/packages/docs/pages/public/favicons/dropbox.svg new file mode 100644 index 0000000..59f3862 --- /dev/null +++ b/packages/docs/pages/public/favicons/dropbox.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/filter.svg b/packages/docs/pages/public/favicons/filter.svg new file mode 100644 index 0000000..4ea1aa9 --- /dev/null +++ b/packages/docs/pages/public/favicons/filter.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/docs/pages/public/favicons/flickr.svg b/packages/docs/pages/public/favicons/flickr.svg new file mode 100644 index 0000000..f8499a7 --- /dev/null +++ b/packages/docs/pages/public/favicons/flickr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/docs/pages/public/favicons/formatter.svg b/packages/docs/pages/public/favicons/formatter.svg new file mode 100644 index 0000000..858aed3 --- /dev/null +++ b/packages/docs/pages/public/favicons/formatter.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/docs/pages/public/favicons/freescout.svg b/packages/docs/pages/public/favicons/freescout.svg new file mode 100644 index 0000000..b2fe641 --- /dev/null +++ b/packages/docs/pages/public/favicons/freescout.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/packages/docs/pages/public/favicons/ghost.svg b/packages/docs/pages/public/favicons/ghost.svg new file mode 100644 index 0000000..e98fb6f --- /dev/null +++ b/packages/docs/pages/public/favicons/ghost.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/github.svg b/packages/docs/pages/public/favicons/github.svg new file mode 100644 index 0000000..b49b4e2 --- /dev/null +++ b/packages/docs/pages/public/favicons/github.svg @@ -0,0 +1,6 @@ + diff --git a/packages/docs/pages/public/favicons/gitlab.svg b/packages/docs/pages/public/favicons/gitlab.svg new file mode 100644 index 0000000..1c7cb07 --- /dev/null +++ b/packages/docs/pages/public/favicons/gitlab.svg @@ -0,0 +1 @@ + diff --git a/packages/docs/pages/public/favicons/google-calendar.svg b/packages/docs/pages/public/favicons/google-calendar.svg new file mode 100644 index 0000000..14b505a --- /dev/null +++ b/packages/docs/pages/public/favicons/google-calendar.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/google-drive.svg b/packages/docs/pages/public/favicons/google-drive.svg new file mode 100644 index 0000000..a8cefd5 --- /dev/null +++ b/packages/docs/pages/public/favicons/google-drive.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/google-forms.svg b/packages/docs/pages/public/favicons/google-forms.svg new file mode 100644 index 0000000..d1836fd --- /dev/null +++ b/packages/docs/pages/public/favicons/google-forms.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/google-sheets.svg b/packages/docs/pages/public/favicons/google-sheets.svg new file mode 100644 index 0000000..9bdc972 --- /dev/null +++ b/packages/docs/pages/public/favicons/google-sheets.svg @@ -0,0 +1,89 @@ + + + + Sheets-icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/google-tasks.svg b/packages/docs/pages/public/favicons/google-tasks.svg new file mode 100644 index 0000000..1de5d7a --- /dev/null +++ b/packages/docs/pages/public/favicons/google-tasks.svg @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/http-request.svg b/packages/docs/pages/public/favicons/http-request.svg new file mode 100644 index 0000000..87c7dae --- /dev/null +++ b/packages/docs/pages/public/favicons/http-request.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/hubspot.svg b/packages/docs/pages/public/favicons/hubspot.svg new file mode 100644 index 0000000..c21891f --- /dev/null +++ b/packages/docs/pages/public/favicons/hubspot.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/docs/pages/public/favicons/invoice-ninja.svg b/packages/docs/pages/public/favicons/invoice-ninja.svg new file mode 100644 index 0000000..59d3f11 --- /dev/null +++ b/packages/docs/pages/public/favicons/invoice-ninja.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/jotform.svg b/packages/docs/pages/public/favicons/jotform.svg new file mode 100644 index 0000000..9950004 --- /dev/null +++ b/packages/docs/pages/public/favicons/jotform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/mailchimp.svg b/packages/docs/pages/public/favicons/mailchimp.svg new file mode 100644 index 0000000..55af85d --- /dev/null +++ b/packages/docs/pages/public/favicons/mailchimp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/mailerlite.svg b/packages/docs/pages/public/favicons/mailerlite.svg new file mode 100644 index 0000000..b382453 --- /dev/null +++ b/packages/docs/pages/public/favicons/mailerlite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/mattermost.svg b/packages/docs/pages/public/favicons/mattermost.svg new file mode 100644 index 0000000..47da0ba --- /dev/null +++ b/packages/docs/pages/public/favicons/mattermost.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/docs/pages/public/favicons/miro.svg b/packages/docs/pages/public/favicons/miro.svg new file mode 100644 index 0000000..b87ea9a --- /dev/null +++ b/packages/docs/pages/public/favicons/miro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/mistral-ai.svg b/packages/docs/pages/public/favicons/mistral-ai.svg new file mode 100644 index 0000000..3f58330 --- /dev/null +++ b/packages/docs/pages/public/favicons/mistral-ai.svg @@ -0,0 +1,32 @@ + + + Mistral AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/notion.svg b/packages/docs/pages/public/favicons/notion.svg new file mode 100644 index 0000000..ebcbe81 --- /dev/null +++ b/packages/docs/pages/public/favicons/notion.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/docs/pages/public/favicons/ntfy.svg b/packages/docs/pages/public/favicons/ntfy.svg new file mode 100644 index 0000000..9e5b513 --- /dev/null +++ b/packages/docs/pages/public/favicons/ntfy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/odoo.svg b/packages/docs/pages/public/favicons/odoo.svg new file mode 100644 index 0000000..aeb5dd7 --- /dev/null +++ b/packages/docs/pages/public/favicons/odoo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/openai.svg b/packages/docs/pages/public/favicons/openai.svg new file mode 100644 index 0000000..b62b84e --- /dev/null +++ b/packages/docs/pages/public/favicons/openai.svg @@ -0,0 +1,6 @@ + + OpenAI + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/openrouter.svg b/packages/docs/pages/public/favicons/openrouter.svg new file mode 100644 index 0000000..e88f91b --- /dev/null +++ b/packages/docs/pages/public/favicons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/perplexity.svg b/packages/docs/pages/public/favicons/perplexity.svg new file mode 100644 index 0000000..b27ffc9 --- /dev/null +++ b/packages/docs/pages/public/favicons/perplexity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/pipedrive.svg b/packages/docs/pages/public/favicons/pipedrive.svg new file mode 100644 index 0000000..5efad42 --- /dev/null +++ b/packages/docs/pages/public/favicons/pipedrive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/placetel.svg b/packages/docs/pages/public/favicons/placetel.svg new file mode 100644 index 0000000..6df467a --- /dev/null +++ b/packages/docs/pages/public/favicons/placetel.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/docs/pages/public/favicons/postgres.svg b/packages/docs/pages/public/favicons/postgres.svg new file mode 100644 index 0000000..0bdb3e3 --- /dev/null +++ b/packages/docs/pages/public/favicons/postgres.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/pushover.svg b/packages/docs/pages/public/favicons/pushover.svg new file mode 100644 index 0000000..28492a9 --- /dev/null +++ b/packages/docs/pages/public/favicons/pushover.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/docs/pages/public/favicons/reddit.svg b/packages/docs/pages/public/favicons/reddit.svg new file mode 100644 index 0000000..e41ae32 --- /dev/null +++ b/packages/docs/pages/public/favicons/reddit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/removebg.svg b/packages/docs/pages/public/favicons/removebg.svg new file mode 100644 index 0000000..8019755 --- /dev/null +++ b/packages/docs/pages/public/favicons/removebg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/rss.svg b/packages/docs/pages/public/favicons/rss.svg new file mode 100644 index 0000000..0c3e502 --- /dev/null +++ b/packages/docs/pages/public/favicons/rss.svg @@ -0,0 +1,6 @@ + diff --git a/packages/docs/pages/public/favicons/salesforce.svg b/packages/docs/pages/public/favicons/salesforce.svg new file mode 100644 index 0000000..e82db67 --- /dev/null +++ b/packages/docs/pages/public/favicons/salesforce.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/scheduler.svg b/packages/docs/pages/public/favicons/scheduler.svg new file mode 100644 index 0000000..d80c101 --- /dev/null +++ b/packages/docs/pages/public/favicons/scheduler.svg @@ -0,0 +1 @@ + diff --git a/packages/docs/pages/public/favicons/signalwire.svg b/packages/docs/pages/public/favicons/signalwire.svg new file mode 100644 index 0000000..1dda203 --- /dev/null +++ b/packages/docs/pages/public/favicons/signalwire.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/slack.svg b/packages/docs/pages/public/favicons/slack.svg new file mode 100644 index 0000000..81629c7 --- /dev/null +++ b/packages/docs/pages/public/favicons/slack.svg @@ -0,0 +1,6 @@ + diff --git a/packages/docs/pages/public/favicons/smtp.svg b/packages/docs/pages/public/favicons/smtp.svg new file mode 100644 index 0000000..57f0fa5 --- /dev/null +++ b/packages/docs/pages/public/favicons/smtp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/docs/pages/public/favicons/spotify.svg b/packages/docs/pages/public/favicons/spotify.svg new file mode 100644 index 0000000..f84a03c --- /dev/null +++ b/packages/docs/pages/public/favicons/spotify.svg @@ -0,0 +1,6 @@ + + Spotify + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/strava.svg b/packages/docs/pages/public/favicons/strava.svg new file mode 100644 index 0000000..ddd7c85 --- /dev/null +++ b/packages/docs/pages/public/favicons/strava.svg @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/stripe.svg b/packages/docs/pages/public/favicons/stripe.svg new file mode 100644 index 0000000..25d00aa --- /dev/null +++ b/packages/docs/pages/public/favicons/stripe.svg @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/telegram-bot.svg b/packages/docs/pages/public/favicons/telegram-bot.svg new file mode 100644 index 0000000..8f16fb1 --- /dev/null +++ b/packages/docs/pages/public/favicons/telegram-bot.svg @@ -0,0 +1,14 @@ + + + Telegram + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/todoist.svg b/packages/docs/pages/public/favicons/todoist.svg new file mode 100644 index 0000000..679cdc6 --- /dev/null +++ b/packages/docs/pages/public/favicons/todoist.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/together-ai.svg b/packages/docs/pages/public/favicons/together-ai.svg new file mode 100644 index 0000000..620ac88 --- /dev/null +++ b/packages/docs/pages/public/favicons/together-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/trello.svg b/packages/docs/pages/public/favicons/trello.svg new file mode 100644 index 0000000..7c63adb --- /dev/null +++ b/packages/docs/pages/public/favicons/trello.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/twilio.svg b/packages/docs/pages/public/favicons/twilio.svg new file mode 100644 index 0000000..7c20e19 --- /dev/null +++ b/packages/docs/pages/public/favicons/twilio.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/twitter.svg b/packages/docs/pages/public/favicons/twitter.svg new file mode 100644 index 0000000..752cdc8 --- /dev/null +++ b/packages/docs/pages/public/favicons/twitter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/docs/pages/public/favicons/typeform.svg b/packages/docs/pages/public/favicons/typeform.svg new file mode 100644 index 0000000..f0fabb1 --- /dev/null +++ b/packages/docs/pages/public/favicons/typeform.svg @@ -0,0 +1,4 @@ + + Typeform + + diff --git a/packages/docs/pages/public/favicons/virtualq.svg b/packages/docs/pages/public/favicons/virtualq.svg new file mode 100644 index 0000000..41162b4 --- /dev/null +++ b/packages/docs/pages/public/favicons/virtualq.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/pages/public/favicons/vtiger-crm.svg b/packages/docs/pages/public/favicons/vtiger-crm.svg new file mode 100644 index 0000000..0d95870 --- /dev/null +++ b/packages/docs/pages/public/favicons/vtiger-crm.svg @@ -0,0 +1,925 @@ + + + + diff --git a/packages/docs/pages/public/favicons/webhooks.svg b/packages/docs/pages/public/favicons/webhooks.svg new file mode 100644 index 0000000..894b13e --- /dev/null +++ b/packages/docs/pages/public/favicons/webhooks.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/docs/pages/public/favicons/wordpress.svg b/packages/docs/pages/public/favicons/wordpress.svg new file mode 100644 index 0000000..39be6e1 --- /dev/null +++ b/packages/docs/pages/public/favicons/wordpress.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/xero.svg b/packages/docs/pages/public/favicons/xero.svg new file mode 100644 index 0000000..e1cd725 --- /dev/null +++ b/packages/docs/pages/public/favicons/xero.svg @@ -0,0 +1 @@ +Xero homepageBeautiful business \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/you-need-a-budget.svg b/packages/docs/pages/public/favicons/you-need-a-budget.svg new file mode 100644 index 0000000..c83333c --- /dev/null +++ b/packages/docs/pages/public/favicons/you-need-a-budget.svg @@ -0,0 +1,25 @@ + + + + My Budget + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/youtube.svg b/packages/docs/pages/public/favicons/youtube.svg new file mode 100644 index 0000000..e54d503 --- /dev/null +++ b/packages/docs/pages/public/favicons/youtube.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/docs/pages/public/favicons/zendesk.svg b/packages/docs/pages/public/favicons/zendesk.svg new file mode 100644 index 0000000..882b451 --- /dev/null +++ b/packages/docs/pages/public/favicons/zendesk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/docs/yarn.lock b/packages/docs/yarn.lock new file mode 100644 index 0000000..88f55d3 --- /dev/null +++ b/packages/docs/yarn.lock @@ -0,0 +1,1192 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@algolia/autocomplete-core@1.17.7": + version "1.17.7" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz#2c410baa94a47c5c5f56ed712bb4a00ebe24088b" + integrity sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q== + dependencies: + "@algolia/autocomplete-plugin-algolia-insights" "1.17.7" + "@algolia/autocomplete-shared" "1.17.7" + +"@algolia/autocomplete-plugin-algolia-insights@1.17.7": + version "1.17.7" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz#7d2b105f84e7dd8f0370aa4c4ab3b704e6760d82" + integrity sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A== + dependencies: + "@algolia/autocomplete-shared" "1.17.7" + +"@algolia/autocomplete-preset-algolia@1.17.7": + version "1.17.7" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz#c9badc0d73d62db5bf565d839d94ec0034680ae9" + integrity sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA== + dependencies: + "@algolia/autocomplete-shared" "1.17.7" + +"@algolia/autocomplete-shared@1.17.7": + version "1.17.7" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz#105e84ad9d1a31d3fb86ba20dc890eefe1a313a0" + integrity sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg== + +"@algolia/client-abtesting@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.15.0.tgz#6414895e2246dc7b7facd97bd98c3abe13cabe59" + integrity sha512-FaEM40iuiv1mAipYyiptP4EyxkJ8qHfowCpEeusdHUC4C7spATJYArD2rX3AxkVeREkDIgYEOuXcwKUbDCr7Nw== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/client-analytics@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.15.0.tgz#7ca1043cba7ac225d30e8bb52579504946b95f58" + integrity sha512-lho0gTFsQDIdCwyUKTtMuf9nCLwq9jOGlLGIeQGKDxXF7HbiAysFIu5QW/iQr1LzMgDyM9NH7K98KY+BiIFriQ== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/client-common@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.15.0.tgz#cd47ae07a3afc7065438a2dab29f8434f848928e" + integrity sha512-IofrVh213VLsDkPoSKMeM9Dshrv28jhDlBDLRcVJQvlL8pzue7PEB1EZ4UoJFYS3NSn7JOcJ/V+olRQzXlJj1w== + +"@algolia/client-insights@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.15.0.tgz#f3bead0edd10e69365895da4a96044064b504f4d" + integrity sha512-bDDEQGfFidDi0UQUCbxXOCdphbVAgbVmxvaV75cypBTQkJ+ABx/Npw7LkFGw1FsoVrttlrrQbwjvUB6mLVKs/w== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/client-personalization@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.15.0.tgz#e962793ebf737a5ffa4867d2dfdfe17924be3833" + integrity sha512-LfaZqLUWxdYFq44QrasCDED5bSYOswpQjSiIL7Q5fYlefAAUO95PzBPKCfUhSwhb4rKxigHfDkd81AvEicIEoA== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/client-query-suggestions@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.15.0.tgz#d9a2d0d0660241bdae5fc36a6f1fcf339abbafeb" + integrity sha512-wu8GVluiZ5+il8WIRsGKu8VxMK9dAlr225h878GGtpTL6VBvwyJvAyLdZsfFIpY0iN++jiNb31q2C1PlPL+n/A== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/client-search@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.15.0.tgz#8645f5bc87a959b8008e021d8b31d55a47920b94" + integrity sha512-Z32gEMrRRpEta5UqVQA612sLdoqY3AovvUPClDfMxYrbdDAebmGDVPtSogUba1FZ4pP5dx20D3OV3reogLKsRA== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/ingestion@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.15.0.tgz#a3f3ec2139042f8597c2a975430a6f77cd764db3" + integrity sha512-MkqkAxBQxtQ5if/EX2IPqFA7LothghVyvPoRNA/meS2AW2qkHwcxjuiBxv4H6mnAVEPfJlhu9rkdVz9LgCBgJg== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/monitoring@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.15.0.tgz#1eb58722ec9ea6e5de3621150f97a43571bd312e" + integrity sha512-QPrFnnGLMMdRa8t/4bs7XilPYnoUXDY8PMQJ1sf9ZFwhUysYYhQNX34/enoO0LBjpoOY6rLpha39YQEFbzgKyQ== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/recommend@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.15.0.tgz#8f3359ee7e855849ac3872f67c0672f6835c8f79" + integrity sha512-5eupMwSqMLDObgSMF0XG958zR6GJP3f7jHDQ3/WlzCM9/YIJiWIUoJFGsko9GYsA5xbLDHE/PhWtq4chcCdaGQ== + dependencies: + "@algolia/client-common" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +"@algolia/requester-browser-xhr@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.15.0.tgz#5ffdccdf5cd7814ed3486bed418edb6db25c32a2" + integrity sha512-Po/GNib6QKruC3XE+WKP1HwVSfCDaZcXu48kD+gwmtDlqHWKc7Bq9lrS0sNZ456rfCKhXksOmMfUs4wRM/Y96w== + dependencies: + "@algolia/client-common" "5.15.0" + +"@algolia/requester-fetch@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.15.0.tgz#2ce94d4855090fac192b208d95eeea22e1ca4489" + integrity sha512-rOZ+c0P7ajmccAvpeeNrUmEKoliYFL8aOR5qGW5pFq3oj3Iept7Y5mEtEsOBYsRt6qLnaXn4zUKf+N8nvJpcIw== + dependencies: + "@algolia/client-common" "5.15.0" + +"@algolia/requester-node-http@5.15.0": + version "5.15.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.15.0.tgz#e2020afcdaea56dc204bc6c82daab41478b32d87" + integrity sha512-b1jTpbFf9LnQHEJP5ddDJKE2sAlhYd7EVSOWgzo/27n/SfCoHfqD0VWntnWYD83PnOKvfe8auZ2+xCb0TXotrQ== + dependencies: + "@algolia/client-common" "5.15.0" + +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/parser@^7.25.3": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" + integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== + dependencies: + "@babel/types" "^7.26.0" + +"@babel/types@^7.26.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" + integrity sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + +"@docsearch/css@3.8.0", "@docsearch/css@^3.6.2": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.8.0.tgz#c70a1a326249d878ab7c630d7a908c6769a38db3" + integrity sha512-pieeipSOW4sQ0+bE5UFC51AOZp9NGxg89wAlZ1BAQFaiRAGK1IKUaPQ0UGZeNctJXyqZ1UvBtOQh2HH+U5GtmA== + +"@docsearch/js@^3.6.2": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docsearch/js/-/js-3.8.0.tgz#978853b44909f0d82782f3af72bb995b68349c00" + integrity sha512-PVuV629f5UcYRtBWqK7ID6vNL5647+2ADJypwTjfeBIrJfwPuHtzLy39hMGMfFK+0xgRyhTR0FZ83EkdEraBlg== + dependencies: + "@docsearch/react" "3.8.0" + preact "^10.0.0" + +"@docsearch/react@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-3.8.0.tgz#c32165e34fadea8a0283c8b61cd73e6e1844797d" + integrity sha512-WnFK720+iwTVt94CxY3u+FgX6exb3BfN5kE9xUY6uuAH/9W/UFboBZFLlrw/zxFRHoHZCOXRtOylsXF+6LHI+Q== + dependencies: + "@algolia/autocomplete-core" "1.17.7" + "@algolia/autocomplete-preset-algolia" "1.17.7" + "@docsearch/css" "3.8.0" + algoliasearch "^5.12.0" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@iconify-json/simple-icons@^1.2.10": + version "1.2.12" + resolved "https://registry.yarnpkg.com/@iconify-json/simple-icons/-/simple-icons-1.2.12.tgz#124a4a5036617aec2d773c60baf370d8ff597523" + integrity sha512-lRNORrIdeLStShxAjN6FgXE1iMkaAgiAHZdP0P0GZecX91FVYW58uZnRSlXLlSx5cxMoELulkAAixybPA2g52g== + dependencies: + "@iconify/types" "*" + +"@iconify/types@*": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" + integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== + +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@rollup/rollup-android-arm-eabi@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.3.tgz#ab2c78c43e4397fba9a80ea93907de7a144f3149" + integrity sha512-EzxVSkIvCFxUd4Mgm4xR9YXrcp976qVaHnqom/Tgm+vU79k4vV4eYTjmRvGfeoW8m9LVcsAy/lGjcgVegKEhLQ== + +"@rollup/rollup-android-arm64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.3.tgz#de840660ab65cf73bd6d4bc62d38acd9fc94cd6c" + integrity sha512-LJc5pDf1wjlt9o/Giaw9Ofl+k/vLUaYsE2zeQGH85giX2F+wn/Cg8b3c5CDP3qmVmeO5NzwVUzQQxwZvC2eQKw== + +"@rollup/rollup-darwin-arm64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.3.tgz#8c786e388f7eff0d830151a9d8fbf04c031bb07f" + integrity sha512-OuRysZ1Mt7wpWJ+aYKblVbJWtVn3Cy52h8nLuNSzTqSesYw1EuN6wKp5NW/4eSre3mp12gqFRXOKTcN3AI3LqA== + +"@rollup/rollup-darwin-x64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.3.tgz#56dab9e4cac0ad97741740ea1ac7b6a576e20e59" + integrity sha512-xW//zjJMlJs2sOrCmXdB4d0uiilZsOdlGQIC/jjmMWT47lkLLoB1nsNhPUcnoqyi5YR6I4h+FjBpILxbEy8JRg== + +"@rollup/rollup-freebsd-arm64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.3.tgz#bcb4112cb7e68a12d148b03cbc21dde43772f4bc" + integrity sha512-58E0tIcwZ+12nK1WiLzHOD8I0d0kdrY/+o7yFVPRHuVGY3twBwzwDdTIBGRxLmyjciMYl1B/U515GJy+yn46qw== + +"@rollup/rollup-freebsd-x64@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.3.tgz#c7cd9f69aa43847b37d819f12c2ad6337ec245fa" + integrity sha512-78fohrpcVwTLxg1ZzBMlwEimoAJmY6B+5TsyAZ3Vok7YabRBUvjYTsRXPTjGEvv/mfgVBepbW28OlMEz4w8wGA== + +"@rollup/rollup-linux-arm-gnueabihf@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.3.tgz#3692b22987a6195c8490bbf6357800e0c183ee38" + integrity sha512-h2Ay79YFXyQi+QZKo3ISZDyKaVD7uUvukEHTOft7kh00WF9mxAaxZsNs3o/eukbeKuH35jBvQqrT61fzKfAB/Q== + +"@rollup/rollup-linux-arm-musleabihf@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.3.tgz#f920f24e571f26bbcdb882267086942fdb2474bf" + integrity sha512-Sv2GWmrJfRY57urktVLQ0VKZjNZGogVtASAgosDZ1aUB+ykPxSi3X1nWORL5Jk0sTIIwQiPH7iE3BMi9zGWfkg== + +"@rollup/rollup-linux-arm64-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.3.tgz#2046553e91d8ca73359a2a3bb471826fbbdcc9a3" + integrity sha512-FPoJBLsPW2bDNWjSrwNuTPUt30VnfM8GPGRoLCYKZpPx0xiIEdFip3dH6CqgoT0RnoGXptaNziM0WlKgBc+OWQ== + +"@rollup/rollup-linux-arm64-musl@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.3.tgz#8a3f05dbae753102ae10a9bc2168c7b6bbeea5da" + integrity sha512-TKxiOvBorYq4sUpA0JT+Fkh+l+G9DScnG5Dqx7wiiqVMiRSkzTclP35pE6eQQYjP4Gc8yEkJGea6rz4qyWhp3g== + +"@rollup/rollup-linux-powerpc64le-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.3.tgz#d281d9c762f9e4f1aa7909a313f7acbe78aced32" + integrity sha512-v2M/mPvVUKVOKITa0oCFksnQQ/TqGrT+yD0184/cWHIu0LoIuYHwox0Pm3ccXEz8cEQDLk6FPKd1CCm+PlsISw== + +"@rollup/rollup-linux-riscv64-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.3.tgz#fa84b3f81826cee0de9e90f9954f3e55c3cc6c97" + integrity sha512-LdrI4Yocb1a/tFVkzmOE5WyYRgEBOyEhWYJe4gsDWDiwnjYKjNs7PS6SGlTDB7maOHF4kxevsuNBl2iOcj3b4A== + +"@rollup/rollup-linux-s390x-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.3.tgz#6b9c04d84593836f942ceb4dd90644633d5fe871" + integrity sha512-d4wVu6SXij/jyiwPvI6C4KxdGzuZOvJ6y9VfrcleHTwo68fl8vZC5ZYHsCVPUi4tndCfMlFniWgwonQ5CUpQcA== + +"@rollup/rollup-linux-x64-gnu@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.3.tgz#f13effcdcd1cc14b26427e6bec8c6c9e4de3773e" + integrity sha512-/6bn6pp1fsCGEY5n3yajmzZQAh+mW4QPItbiWxs69zskBzJuheb3tNynEjL+mKOsUSFK11X4LYF2BwwXnzWleA== + +"@rollup/rollup-linux-x64-musl@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.3.tgz#6547bc0069f2d788e6cf0f33363b951181f4cca5" + integrity sha512-nBXOfJds8OzUT1qUreT/en3eyOXd2EH5b0wr2bVB5999qHdGKkzGzIyKYaKj02lXk6wpN71ltLIaQpu58YFBoQ== + +"@rollup/rollup-win32-arm64-msvc@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.3.tgz#3f2db9347c5df5e6627a7e12d937cea527d63526" + integrity sha512-ogfbEVQgIZOz5WPWXF2HVb6En+kWzScuxJo/WdQTqEgeyGkaa2ui5sQav9Zkr7bnNCLK48uxmmK0TySm22eiuw== + +"@rollup/rollup-win32-ia32-msvc@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.3.tgz#54fcf9a13a98d3f0e4be6a4b6e28b9dca676502f" + integrity sha512-ecE36ZBMLINqiTtSNQ1vzWc5pXLQHlf/oqGp/bSbi7iedcjcNb6QbCBNG73Euyy2C+l/fn8qKWEwxr+0SSfs3w== + +"@rollup/rollup-win32-x64-msvc@4.27.3": + version "4.27.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.3.tgz#3721f601f973059bfeeb572992cf0dfc94ab2970" + integrity sha512-vliZLrDmYKyaUoMzEbMTg2JkerfBjn03KmAw9CykO0Zzkzoyd7o3iZNam/TpyWNjNT+Cz2iO3P9Smv2wgrR+Eg== + +"@shikijs/core@1.23.1", "@shikijs/core@^1.22.2": + version "1.23.1" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.23.1.tgz#911473e672e4f2d15ca36b28b28179c0959aa7af" + integrity sha512-NuOVgwcHgVC6jBVH5V7iblziw6iQbWWHrj5IlZI3Fqu2yx9awH7OIQkXIcsHsUmY19ckwSgUMgrqExEyP5A0TA== + dependencies: + "@shikijs/engine-javascript" "1.23.1" + "@shikijs/engine-oniguruma" "1.23.1" + "@shikijs/types" "1.23.1" + "@shikijs/vscode-textmate" "^9.3.0" + "@types/hast" "^3.0.4" + hast-util-to-html "^9.0.3" + +"@shikijs/engine-javascript@1.23.1": + version "1.23.1" + resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-1.23.1.tgz#0f634bea22cb14f471835b7b5f1da66bc34bd359" + integrity sha512-i/LdEwT5k3FVu07SiApRFwRcSJs5QM9+tod5vYCPig1Ywi8GR30zcujbxGQFJHwYD7A5BUqagi8o5KS+LEVgBg== + dependencies: + "@shikijs/types" "1.23.1" + "@shikijs/vscode-textmate" "^9.3.0" + oniguruma-to-es "0.4.1" + +"@shikijs/engine-oniguruma@1.23.1": + version "1.23.1" + resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-1.23.1.tgz#c6c34c9152cf90c1ee75fcdbd124253c8ad0635f" + integrity sha512-KQ+lgeJJ5m2ISbUZudLR1qHeH3MnSs2mjFg7bnencgs5jDVPeJ2NVDJ3N5ZHbcTsOIh0qIueyAJnwg7lg7kwXQ== + dependencies: + "@shikijs/types" "1.23.1" + "@shikijs/vscode-textmate" "^9.3.0" + +"@shikijs/transformers@^1.22.2": + version "1.23.1" + resolved "https://registry.yarnpkg.com/@shikijs/transformers/-/transformers-1.23.1.tgz#44fe7bef7064da9e5d79df73c6a1d48cc6d75072" + integrity sha512-yQ2Cn0M9i46p30KwbyIzLvKDk+dQNU+lj88RGO0XEj54Hn4Cof1bZoDb9xBRWxFE4R8nmK63w7oHnJwvOtt0NQ== + dependencies: + shiki "1.23.1" + +"@shikijs/types@1.23.1", "@shikijs/types@^1.22.2": + version "1.23.1" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-1.23.1.tgz#2386d49258be03e7b40fea1f28fda952739ad93d" + integrity sha512-98A5hGyEhzzAgQh2dAeHKrWW4HfCMeoFER2z16p5eJ+vmPeF6lZ/elEne6/UCU551F/WqkopqRsr1l2Yu6+A0g== + dependencies: + "@shikijs/vscode-textmate" "^9.3.0" + "@types/hast" "^3.0.4" + +"@shikijs/vscode-textmate@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-9.3.0.tgz#b2f1776e488c1d6c2b6cd129bab62f71bbc9c7ab" + integrity sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA== + +"@types/estree@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + +"@types/hast@^3.0.0", "@types/hast@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + +"@types/linkify-it@^5": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" + integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== + +"@types/markdown-it@^14.1.2": + version "14.1.2" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" + integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== + dependencies: + "@types/linkify-it" "^5" + "@types/mdurl" "^2" + +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/mdurl@^2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" + integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== + +"@types/node@*": + version "22.9.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.1.tgz#bdf91c36e0e7ecfb7257b2d75bf1b206b308ca71" + integrity sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg== + dependencies: + undici-types "~6.19.8" + +"@types/node@^17.0.5": + version "17.0.45" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" + integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== + +"@types/sax@^1.2.1": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.7.tgz#ba5fe7df9aa9c89b6dff7688a19023dd2963091d" + integrity sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A== + dependencies: + "@types/node" "*" + +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + +"@ungap/structured-clone@^1.0.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +"@vitejs/plugin-vue@^5.1.4": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.0.tgz#994f3b4f12d3590c5a6895df4cbd270d9a6d5e17" + integrity sha512-7n7KdUEtx/7Yl7I/WVAMZ1bEb0eVvXF3ummWTeLcs/9gvo9pJhuLdouSXGjdZ/MKD1acf1I272+X0RMua4/R3g== + +"@vue/compiler-core@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05" + integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q== + dependencies: + "@babel/parser" "^7.25.3" + "@vue/shared" "3.5.13" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.0" + +"@vue/compiler-dom@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58" + integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA== + dependencies: + "@vue/compiler-core" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/compiler-sfc@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46" + integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ== + dependencies: + "@babel/parser" "^7.25.3" + "@vue/compiler-core" "3.5.13" + "@vue/compiler-dom" "3.5.13" + "@vue/compiler-ssr" "3.5.13" + "@vue/shared" "3.5.13" + estree-walker "^2.0.2" + magic-string "^0.30.11" + postcss "^8.4.48" + source-map-js "^1.2.0" + +"@vue/compiler-ssr@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba" + integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA== + dependencies: + "@vue/compiler-dom" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/devtools-api@^7.5.4": + version "7.6.4" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.6.4.tgz#024bf0ecf543395844f4d97cff0a84f8f759b29d" + integrity sha512-5AaJ5ELBIuevmFMZYYLuOO9HUuY/6OlkOELHE7oeDhy4XD/hSODIzktlsvBOsn+bto3aD0psj36LGzwVu5Ip8w== + dependencies: + "@vue/devtools-kit" "^7.6.4" + +"@vue/devtools-kit@^7.6.4": + version "7.6.4" + resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.6.4.tgz#2a74750d5604b6b3c2fe3388a454c9eac2c6c1f4" + integrity sha512-Zs86qIXXM9icU0PiGY09PQCle4TI750IPLmAJzW5Kf9n9t5HzSYf6Rz6fyzSwmfMPiR51SUKJh9sXVZu78h2QA== + dependencies: + "@vue/devtools-shared" "^7.6.4" + birpc "^0.2.19" + hookable "^5.5.3" + mitt "^3.0.1" + perfect-debounce "^1.0.0" + speakingurl "^14.0.1" + superjson "^2.2.1" + +"@vue/devtools-shared@^7.6.4": + version "7.6.4" + resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.6.4.tgz#110044c88bafee3b2daa992fd90730546dec7b11" + integrity sha512-nD6CUvBEel+y7zpyorjiUocy0nh77DThZJ0k1GRnJeOmY3ATq2fWijEp7wk37gb023Cb0R396uYh5qMSBQ5WFg== + dependencies: + rfdc "^1.4.1" + +"@vue/reactivity@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f" + integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg== + dependencies: + "@vue/shared" "3.5.13" + +"@vue/runtime-core@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455" + integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw== + dependencies: + "@vue/reactivity" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/runtime-dom@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215" + integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog== + dependencies: + "@vue/reactivity" "3.5.13" + "@vue/runtime-core" "3.5.13" + "@vue/shared" "3.5.13" + csstype "^3.1.3" + +"@vue/server-renderer@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7" + integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA== + dependencies: + "@vue/compiler-ssr" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/shared@3.5.13", "@vue/shared@^3.5.12": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f" + integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== + +"@vueuse/core@11.2.0", "@vueuse/core@^11.1.0": + version "11.2.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-11.2.0.tgz#3fc6c0963051bb154dc4c08061889405e3fc745d" + integrity sha512-JIUwRcOqOWzcdu1dGlfW04kaJhW3EXnnjJJfLTtddJanymTL7lF1C0+dVVZ/siLfc73mWn+cGP1PE1PKPruRSA== + dependencies: + "@types/web-bluetooth" "^0.0.20" + "@vueuse/metadata" "11.2.0" + "@vueuse/shared" "11.2.0" + vue-demi ">=0.14.10" + +"@vueuse/integrations@^11.1.0": + version "11.2.0" + resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-11.2.0.tgz#c4c2dd82260265697e0ec8d6356a7d2744acb896" + integrity sha512-zGXz3dsxNHKwiD9jPMvR3DAxQEOV6VWIEYTGVSB9PNpk4pTWR+pXrHz9gvXWcP2sTk3W2oqqS6KwWDdntUvNVA== + dependencies: + "@vueuse/core" "11.2.0" + "@vueuse/shared" "11.2.0" + vue-demi ">=0.14.10" + +"@vueuse/metadata@11.2.0": + version "11.2.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-11.2.0.tgz#fd02cbbc7d08cb4592fceea0486559b89ae38643" + integrity sha512-L0ZmtRmNx+ZW95DmrgD6vn484gSpVeRbgpWevFKXwqqQxW9hnSi2Ppuh2BzMjnbv4aJRiIw8tQatXT9uOB23dQ== + +"@vueuse/shared@11.2.0": + version "11.2.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-11.2.0.tgz#7fb2f3cade6b6c00ef97e613f187ee9bdcfb9a3a" + integrity sha512-VxFjie0EanOudYSgMErxXfq6fo8vhr5ICI+BuE3I9FnX7ePllEsVrRQ7O6Q1TLgApeLuPKcHQxAXpP+KnlrJsg== + dependencies: + vue-demi ">=0.14.10" + +algoliasearch@^5.12.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.15.0.tgz#09cef5a2555c4554b37a99f0488ea6ab2347e625" + integrity sha512-Yf3Swz1s63hjvBVZ/9f2P1Uu48GjmjCN+Esxb6MAONMGtZB1fRX8/S1AhUTtsuTlcGovbYLxpHgc7wEzstDZBw== + dependencies: + "@algolia/client-abtesting" "5.15.0" + "@algolia/client-analytics" "5.15.0" + "@algolia/client-common" "5.15.0" + "@algolia/client-insights" "5.15.0" + "@algolia/client-personalization" "5.15.0" + "@algolia/client-query-suggestions" "5.15.0" + "@algolia/client-search" "5.15.0" + "@algolia/ingestion" "1.15.0" + "@algolia/monitoring" "1.15.0" + "@algolia/recommend" "5.15.0" + "@algolia/requester-browser-xhr" "5.15.0" + "@algolia/requester-fetch" "5.15.0" + "@algolia/requester-node-http" "5.15.0" + +arg@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + +birpc@^0.2.19: + version "0.2.19" + resolved "https://registry.yarnpkg.com/birpc/-/birpc-0.2.19.tgz#cdd183a4a70ba103127d49765b4a71349da5a0ca" + integrity sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ== + +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + +copy-anything@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" + integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== + dependencies: + is-what "^4.1.8" + +csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +emoji-regex-xs@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724" + integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg== + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +focus-trap@^7.6.0: + version "7.6.2" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.6.2.tgz#a501988821ca23d0150a7229eb7a20a3695bdf0e" + integrity sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w== + dependencies: + tabbable "^6.2.0" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +hast-util-to-html@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.3.tgz#a9999a0ba6b4919576a9105129fead85d37f302b" + integrity sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^6.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + +magic-string@^0.30.11: + version "0.30.13" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.13.tgz#92438e3ff4946cf54f18247c981e5c161c46683c" + integrity sha512-8rYBO+MsWkgjDSOvLomYnzhdwEG51olQ4zL5KXnNJWV5MNmrb4rTZdrtkhxjnD/QyZUqR/Z/XDsUs/4ej2nx0g== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +mark.js@8.11.1: + version "8.11.1" + resolved "https://registry.yarnpkg.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ== + +mdast-util-to-hast@^13.0.0: + version "13.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz#5ca58e5b921cc0a3ded1bc02eed79a4fe4fe41f4" + integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.1.tgz#a3edfda3022c6c6b55bfb049ef5b75d70af50709" + integrity sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ== + +minisearch@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/minisearch/-/minisearch-7.1.0.tgz#f5830e9109b5919ee7b291c29a304f381aa68770" + integrity sha512-tv7c/uefWdEhcu6hvrfTihflgeEi2tN6VV7HJnCjK6VxM75QQJh4t9FwJCsA2EsRS8LCnu3W87CuGPWMocOLCA== + +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +oniguruma-to-es@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-0.4.1.tgz#112fbcd5fafe4f635983425a6db88f3e2de37107" + integrity sha512-rNcEohFz095QKGRovP/yqPIKc+nP+Sjs4YTHMv33nMePGKrq/r2eu9Yh4646M5XluGJsUnmwoXuiXE69KDs+fQ== + dependencies: + emoji-regex-xs "^1.0.0" + regex "^5.0.0" + regex-recursion "^4.2.1" + +perfect-debounce@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a" + integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.4.43, postcss@^8.4.48: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +preact@^10.0.0: + version "10.24.3" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.24.3.tgz#086386bd47071e3b45410ef20844c21e23828f64" + integrity sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA== + +property-information@^6.0.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec" + integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig== + +regex-recursion@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-4.2.1.tgz#024ee28593b8158e568307b99bf1b7a3d5ea31e9" + integrity sha512-QHNZyZAeKdndD1G3bKAbBEKOSSK4KOHQrAJ01N1LJeb0SoH4DJIeFhp0uUpETgONifS4+P3sOgoA1dhzgrQvhA== + dependencies: + regex-utilities "^2.3.0" + +regex-utilities@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280" + integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng== + +regex@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/regex/-/regex-5.0.2.tgz#291d960467e6499a79ceec022d20a4e0df67c54f" + integrity sha512-/pczGbKIQgfTMRV0XjABvc5RzLqQmwqxLHdQao2RTXPk+pmTXB2P0IaUHYdYyk412YLwUIkaeMd5T+RzVgTqnQ== + dependencies: + regex-utilities "^2.3.0" + +rfdc@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rollup@^4.20.0: + version "4.27.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.27.3.tgz#078ecb20830c1de1f5486607f3e2f490269fb98a" + integrity sha512-SLsCOnlmGt9VoZ9Ek8yBK8tAdmPHeppkw+Xa7yDlCEhDTvwYei03JlWo1fdc7YTfLZ4tD8riJCUyAgTbszk1fQ== + dependencies: + "@types/estree" "1.0.6" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.27.3" + "@rollup/rollup-android-arm64" "4.27.3" + "@rollup/rollup-darwin-arm64" "4.27.3" + "@rollup/rollup-darwin-x64" "4.27.3" + "@rollup/rollup-freebsd-arm64" "4.27.3" + "@rollup/rollup-freebsd-x64" "4.27.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.27.3" + "@rollup/rollup-linux-arm-musleabihf" "4.27.3" + "@rollup/rollup-linux-arm64-gnu" "4.27.3" + "@rollup/rollup-linux-arm64-musl" "4.27.3" + "@rollup/rollup-linux-powerpc64le-gnu" "4.27.3" + "@rollup/rollup-linux-riscv64-gnu" "4.27.3" + "@rollup/rollup-linux-s390x-gnu" "4.27.3" + "@rollup/rollup-linux-x64-gnu" "4.27.3" + "@rollup/rollup-linux-x64-musl" "4.27.3" + "@rollup/rollup-win32-arm64-msvc" "4.27.3" + "@rollup/rollup-win32-ia32-msvc" "4.27.3" + "@rollup/rollup-win32-x64-msvc" "4.27.3" + fsevents "~2.3.2" + +sax@^1.2.4: + version "1.4.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" + integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== + +shiki@1.23.1, shiki@^1.22.2: + version "1.23.1" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-1.23.1.tgz#02f149e8f2592509e701f3a806fd4f3dd64d17e9" + integrity sha512-8kxV9TH4pXgdKGxNOkrSMydn1Xf6It8lsle0fiqxf7a1149K1WGtdOu3Zb91T5r1JpvRPxqxU3C2XdZZXQnrig== + dependencies: + "@shikijs/core" "1.23.1" + "@shikijs/engine-javascript" "1.23.1" + "@shikijs/engine-oniguruma" "1.23.1" + "@shikijs/types" "1.23.1" + "@shikijs/vscode-textmate" "^9.3.0" + "@types/hast" "^3.0.4" + +sitemap@^7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/sitemap/-/sitemap-7.1.2.tgz#6ce1deb43f6f177c68bc59cf93632f54e3ae6b72" + integrity sha512-ARCqzHJ0p4gWt+j7NlU5eDlIO9+Rkr/JhPFZKKQ1l5GCus7rJH4UdrlVAh0xC/gDS/Qir2UMxqYNHtsKr2rpCw== + dependencies: + "@types/node" "^17.0.5" + "@types/sax" "^1.2.1" + arg "^5.0.0" + sax "^1.2.4" + +source-map-js@^1.2.0, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + +speakingurl@^14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53" + integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== + +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + +superjson@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.1.tgz#9377a7fa80fedb10c851c9dbffd942d4bcf79733" + integrity sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA== + dependencies: + copy-anything "^3.0.2" + +tabbable@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +undici-types@~6.19.8: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + +unist-util-is@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + +vite@^5.4.10: + version "5.4.11" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" + integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitepress@^1.0.0-alpha.21: + version "1.5.0" + resolved "https://registry.yarnpkg.com/vitepress/-/vitepress-1.5.0.tgz#61870b27dc9a580e46cea92989f64cdcb550dc23" + integrity sha512-q4Q/G2zjvynvizdB3/bupdYkCJe2umSAMv9Ju4d92E6/NXJ59z70xB0q5p/4lpRyAwflDsbwy1mLV9Q5+nlB+g== + dependencies: + "@docsearch/css" "^3.6.2" + "@docsearch/js" "^3.6.2" + "@iconify-json/simple-icons" "^1.2.10" + "@shikijs/core" "^1.22.2" + "@shikijs/transformers" "^1.22.2" + "@shikijs/types" "^1.22.2" + "@types/markdown-it" "^14.1.2" + "@vitejs/plugin-vue" "^5.1.4" + "@vue/devtools-api" "^7.5.4" + "@vue/shared" "^3.5.12" + "@vueuse/core" "^11.1.0" + "@vueuse/integrations" "^11.1.0" + focus-trap "^7.6.0" + mark.js "8.11.1" + minisearch "^7.1.0" + shiki "^1.22.2" + vite "^5.4.10" + vue "^3.5.12" + +vue-demi@>=0.14.10: + version "0.14.10" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" + integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== + +vue@^3.2.37, vue@^3.5.12: + version "3.5.13" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a" + integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== + dependencies: + "@vue/compiler-dom" "3.5.13" + "@vue/compiler-sfc" "3.5.13" + "@vue/runtime-dom" "3.5.13" + "@vue/server-renderer" "3.5.13" + "@vue/shared" "3.5.13" + +zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/packages/e2e-tests/.env-example b/packages/e2e-tests/.env-example new file mode 100644 index 0000000..0cf8775 --- /dev/null +++ b/packages/e2e-tests/.env-example @@ -0,0 +1,6 @@ +POSTGRES_DB=automatisch +POSTGRES_USER=automatisch_user +POSTGRES_PASSWORD=automatisch_password +POSTGRES_PORT=5432 +POSTGRES_HOST=localhost +BACKEND_APP_URL=http://localhost:3000 \ No newline at end of file diff --git a/packages/e2e-tests/.eslintignore b/packages/e2e-tests/.eslintignore new file mode 100644 index 0000000..e269917 --- /dev/null +++ b/packages/e2e-tests/.eslintignore @@ -0,0 +1,6 @@ +node_modules +build + +.eslintrc.js + +playwright-report/* \ No newline at end of file diff --git a/packages/e2e-tests/.eslintrc.json b/packages/e2e-tests/.eslintrc.json new file mode 100644 index 0000000..943e6da --- /dev/null +++ b/packages/e2e-tests/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "root": true, + "env": { + "node": true, + "es6": true + }, + "extends": [ + "eslint:recommended", + "prettier" + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "rules": { + "semi": [ + 2, + "always" + ], + "indent": [ + "error", + 2 + ] + } +} \ No newline at end of file diff --git a/packages/e2e-tests/.gitignore b/packages/e2e-tests/.gitignore new file mode 100644 index 0000000..16bda59 --- /dev/null +++ b/packages/e2e-tests/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +/output diff --git a/packages/e2e-tests/README.md b/packages/e2e-tests/README.md new file mode 100644 index 0000000..df54b0b --- /dev/null +++ b/packages/e2e-tests/README.md @@ -0,0 +1,59 @@ +# Setting up the test environment +In order to get tests running, there are a few requirements + +1. Setting up the development/test environment +2. Installing playwright +3. Installing the test browsers +4. Setting up environment variables for testing +5. Running in vscode + +## Setting up the development/test environment + +Following the instructions found in the [development documentation](https://automatisch.io/docs/contributing/development-setup) is a great place to start. Note there is one **caveat** + +> You should have the backend server be running off of a **non-production database**. This is because the test suite will actively **drop the database and reset** between test runs in order to ensure repeatability of tests. + +## Installing playwright and test browsers + +You can install all the required packages by going into the tests package + +```sh +cd packages/e2e-tests +``` + +and installing the required dependencies with + +```sh +yarn install +``` + +At the end of installation, this should display a CLI for installing the test browsers. For more information, check out the [Playwright documentation](https://playwright.dev/docs/intro#installing-playwright). + +### Installing the test browsers + +If you find you need to install the browsers for running tests at a later time, you can run + +```sh +npx playwright install +``` + +and it should install the associated browsers for the test running. For more information, check out the [Playwright documentation](https://playwright.dev/docs/browsers#install-browsers). + + +## Running in VSCode + +We recommend using [Playwright Test for VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) maintained by Microsoft. This lets you run playwright tests from within the code editor, giving you access to additional tools, such as easily running subsets of tests. + +[Global setup and teardown](https://playwright.dev/docs/test-global-setup-teardown) are part of the tests. + +By running `yarn test` setup and teardown actions will take place. + +If you need to setup Admin account (if you didn't seed the DB with the admin account or have clean DB) you should run `auth.setup.js` file. + +If you want to clean the database (drop tables) and perform required migrations run `global.teardown.js`. + +# Test failures + +If there are failing tests in the test suite, this can be caused by a myriad of reasons, but one of the best places to start is either running the test in a headed browser, looking at the associated trace file for the failed test, or checking out the output of a failed GitHub Action. + +Playwright has their [own documentation]() on the trace viewer which is very helpful for reviewing the exact browser steps made during a failed test execution. diff --git a/packages/e2e-tests/fixtures/accept-invitation-page.js b/packages/e2e-tests/fixtures/accept-invitation-page.js new file mode 100644 index 0000000..d69ecfd --- /dev/null +++ b/packages/e2e-tests/fixtures/accept-invitation-page.js @@ -0,0 +1,46 @@ +const { expect } = require('@playwright/test'); +const { BasePage } = require('./base-page'); + +export class AcceptInvitation extends BasePage { + path = '/accept-invitation'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.page = page; + this.passwordTextField = this.page.getByTestId('password-text-field'); + this.passwordConfirmationTextField = this.page.getByTestId('confirm-password-text-field'); + this.submitButton = this.page.getByTestId('submit-button'); + this.pageTitle = this.page.getByTestId('accept-invitation-form-title'); + this.formErrorMessage = this.page.getByTestId('accept-invitation-form-error'); + } + + async open(token) { + return await this.page.goto(`${this.path}?token=${token}`); + } + + async acceptInvitation( + password + ) { + await this.passwordTextField.fill(password); + await this.passwordConfirmationTextField.fill(password); + + await this.submitButton.click(); + } + + async fillPasswordField(password) { + await this.passwordTextField.fill(password); + await this.passwordConfirmationTextField.fill(password); + } + + async excpectSubmitButtonToBeDisabled() { + await expect(this.submitButton).toBeDisabled(); + } + + async expectAlertToBeVisible() { + await expect(this.formErrorMessage).toBeVisible(); + } +} diff --git a/packages/e2e-tests/fixtures/admin-setup-page.js b/packages/e2e-tests/fixtures/admin-setup-page.js new file mode 100644 index 0000000..6d5b85c --- /dev/null +++ b/packages/e2e-tests/fixtures/admin-setup-page.js @@ -0,0 +1,80 @@ +import { BasePage } from './base-page'; +const { faker } = require('@faker-js/faker'); +const { expect } = require('@playwright/test'); + +export class AdminSetupPage extends BasePage { + path = '/installation'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.fullNameTextField = this.page.getByTestId('fullName-text-field'); + this.emailTextField = this.page.getByTestId('email-text-field'); + this.passwordTextField = this.page.getByTestId('password-text-field'); + this.repeatPasswordTextField = this.page.getByTestId( + 'repeat-password-text-field' + ); + this.createAdminButton = this.page.getByTestId('installation-button'); + this.invalidFields = this.page.locator('p.Mui-error'); + this.successAlert = this.page.getByTestId('success-alert'); + } + + async open() { + return await this.page.goto(this.path); + } + + async fillValidUserData() { + await this.fullNameTextField.fill(process.env.LOGIN_EMAIL); + await this.emailTextField.fill(process.env.LOGIN_EMAIL); + await this.passwordTextField.fill(process.env.LOGIN_PASSWORD); + await this.repeatPasswordTextField.fill(process.env.LOGIN_PASSWORD); + } + + async fillInvalidUserData() { + await this.fullNameTextField.fill(''); + await this.emailTextField.fill('abcde'); + await this.passwordTextField.fill(''); + await this.repeatPasswordTextField.fill('a'); + } + + async fillNotMatchingPasswordUserData() { + const testUser = this.generateUser(); + await this.fullNameTextField.fill(testUser.fullName); + await this.emailTextField.fill(testUser.email); + await this.passwordTextField.fill(testUser.password); + await this.repeatPasswordTextField.fill(testUser.wronglyRepeatedPassword); + } + + async submitAdminForm() { + await this.createAdminButton.click(); + } + + async expectInvalidFields(errorCount) { + await expect(await this.invalidFields.all()).toHaveLength(errorCount); + } + + async expectSuccessAlertToBeVisible() { + await expect(await this.successAlert).toBeVisible(); + } + + async expectSuccessMessageToContainLoginLink() { + await expect(await this.successAlert.locator('a')).toHaveAttribute( + 'href', + '/login' + ); + } + + generateUser() { + faker.seed(Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER)); + + return { + fullName: faker.person.fullName(), + email: faker.internet.email(), + password: faker.internet.password(), + wronglyRepeatedPassword: faker.internet.password(), + }; + } +} diff --git a/packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js b/packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js new file mode 100644 index 0000000..e0258ee --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; + +const { AuthenticatedPage } = require('../authenticated-page'); + +export class AdminApplicationOAuthClientsPage extends AuthenticatedPage { + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.authClientsTab = this.page.getByTestId('oauth-clients-tab'); + this.saveButton = this.page.getByTestId('submitButton'); + this.successSnackbar = this.page.getByTestId( + 'snackbar-save-admin-apps-settings-success' + ); + this.createFirstAuthClientButton = this.page.getByTestId('no-results'); + this.createAuthClientButton = this.page.getByTestId( + 'create-auth-client-button' + ); + this.submitAuthClientFormButton = this.page.getByTestId( + 'submit-auth-client-form' + ); + this.authClientEntry = this.page.getByTestId('auth-client'); + } + + async openAuthClientsTab() { + this.authClientsTab.click(); + } + + async openFirstAuthClientCreateForm() { + this.createFirstAuthClientButton.click(); + } + + async openAuthClientCreateForm() { + this.createAuthClientButton.click(); + } + + async submitAuthClientForm() { + this.submitAuthClientFormButton.click(); + } + + async authClientShouldBeVisible(authClientName) { + await expect( + this.authClientEntry.filter({ hasText: authClientName }) + ).toBeVisible(); + } +} diff --git a/packages/e2e-tests/fixtures/admin/application-settings-page.js b/packages/e2e-tests/fixtures/admin/application-settings-page.js new file mode 100644 index 0000000..2e756d2 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/application-settings-page.js @@ -0,0 +1,52 @@ +const { AuthenticatedPage } = require('../authenticated-page'); +const { expect } = require('@playwright/test'); + +export class AdminApplicationSettingsPage extends AuthenticatedPage { + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.useOnlyPredefinedAuthClients = page.locator( + '[name="useOnlyPredefinedAuthClients"]' + ); + this.disableConnectionsSwitch = page.locator('[name="disabled"]'); + this.saveButton = page.getByTestId('submit-button'); + this.successSnackbar = page.getByTestId( + 'snackbar-save-admin-apps-settings-success' + ); + } + + async allowUseOnlyPredefinedAuthClients() { + await expect(this.useOnlyPredefinedAuthClients).not.toBeChecked(); + await this.useOnlyPredefinedAuthClients.check(); + } + + async disallowUseOnlyPredefinedAuthClients() { + await expect(this.useOnlyPredefinedAuthClients).toBeChecked(); + await this.useOnlyPredefinedAuthClients.uncheck(); + await expect(this.useOnlyPredefinedAuthClients).not.toBeChecked(); + } + + async disallowConnections() { + await expect(this.disableConnectionsSwitch).not.toBeChecked(); + await this.disableConnectionsSwitch.check(); + } + + async allowConnections() { + await expect(this.disableConnectionsSwitch).toBeChecked(); + await this.disableConnectionsSwitch.uncheck(); + } + + async saveSettings() { + await this.saveButton.click(); + } + + async expectSuccessSnackbarToBeVisible() { + const snackbars = await this.successSnackbar.all(); + for (const snackbar of snackbars) { + await expect(snackbar).toBeVisible(); + } + } +} diff --git a/packages/e2e-tests/fixtures/admin/applications-page.js b/packages/e2e-tests/fixtures/admin/applications-page.js new file mode 100644 index 0000000..77427c1 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/applications-page.js @@ -0,0 +1,32 @@ +const { AuthenticatedPage } = require('../authenticated-page'); + +export class AdminApplicationsPage extends AuthenticatedPage { + screenshotPath = '/admin-settings/apps'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.searchInput = page.locator('[id="search-input"]'); + this.appRow = page.getByTestId('app-row'); + this.appsDrawerLink = page.getByTestId('apps-drawer-link'); + this.appsLoader = page.getByTestId('apps-loader'); + } + + async openApplication(appName) { + await this.searchInput.fill(appName); + await this.appRow.locator(this.page.getByText(appName)).click(); + } + + async navigateTo() { + await this.profileMenuButton.click(); + await this.adminMenuItem.click(); + await this.appsDrawerLink.click(); + await this.isMounted(); + await this.appsLoader.waitFor({ + state: 'detached', + }); + } +} diff --git a/packages/e2e-tests/fixtures/admin/create-role-page.js b/packages/e2e-tests/fixtures/admin/create-role-page.js new file mode 100644 index 0000000..fe0ecde --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/create-role-page.js @@ -0,0 +1,114 @@ +import { expect } from '@playwright/test'; + +const { AuthenticatedPage } = require('../authenticated-page'); +const { RoleConditionsModal } = require('./role-conditions-modal'); + +export class AdminCreateRolePage extends AuthenticatedPage { + screenshotPath = '/admin/create-role'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + this.nameInput = page.getByTestId('name-input'); + this.descriptionInput = page.getByTestId('description-input'); + this.createButton = page.getByTestId('create-button'); + this.connectionRow = page.getByTestId('Connection-permission-row'); + this.executionRow = page.getByTestId('Execution-permission-row'); + this.flowRow = page.getByTestId('Flow-permission-row'); + this.pageTitle = page.getByTestId('create-role-title'); + this.permissionsCatalog = page.getByTestId('permissions-catalog'); + } + + /** + * @param {('Connection'|'Execution'|'Flow')} subject + */ + getRoleConditionsModal(subject) { + return new RoleConditionsModal(this.page, subject); + } + + async getPermissionConfigs() { + const subjects = ['Connection', 'Flow', 'Execution']; + const permissionConfigs = []; + for (let subject of subjects) { + const row = this.getSubjectRow(subject); + const actionInputs = await this.getRowInputs(row); + Object.keys(actionInputs).forEach((action) => { + permissionConfigs.push({ + action, + locator: actionInputs[action], + subject, + row, + }); + }); + } + return permissionConfigs; + } + + /** + * + * @param {( + * 'Connection' | 'Flow' | 'Execution' + * )} subject + */ + getSubjectRow(subject) { + const k = `${subject.toLowerCase()}Row`; + if (this[k]) { + return this[k]; + } else { + throw 'Unknown row'; + } + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async getRowInputs(row) { + const inputs = { + // settingsButton: row.getByTestId('permission-settings-button') + }; + for (let input of ['create', 'read', 'update', 'delete', 'publish']) { + const testId = `${input}-checkbox`; + if ((await row.getByTestId(testId).count()) > 0) { + inputs[input] = row.getByTestId(testId).locator('input'); + } + } + return inputs; + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async clickPermissionSettings(row) { + await row.getByTestId('permission-settings-button').click(); + } + + /** + * + * @param {string} subject + * @param {'create'|'read'|'update'|'delete'|'publish'} action + * @param {boolean} val + */ + async updateAction(subject, action, val) { + const row = await this.getSubjectRow(subject); + const inputs = await this.getRowInputs(row); + if (inputs[action]) { + if (await inputs[action].isChecked()) { + if (!val) { + await inputs[action].click(); + } + } else { + if (val) { + await inputs[action].click(); + } + } + } else { + throw new Error(`${subject} does not have action ${action}`); + } + } + + async waitForPermissionsCatalogToVisible() { + await expect(this.permissionsCatalog).toBeVisible(); + } +} diff --git a/packages/e2e-tests/fixtures/admin/create-user-page.js b/packages/e2e-tests/fixtures/admin/create-user-page.js new file mode 100644 index 0000000..ddf0f6e --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/create-user-page.js @@ -0,0 +1,43 @@ +const { expect } = require('@playwright/test'); + +const { faker } = require('@faker-js/faker'); +const { AuthenticatedPage } = require('../authenticated-page'); + +export class AdminCreateUserPage extends AuthenticatedPage { + screenshot = '/admin/create-user'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + this.fullNameInput = page.getByTestId('full-name-input'); + this.emailInput = page.getByTestId('email-input'); + this.roleInput = page.getByTestId('roleId-autocomplete'); + this.createButton = page.getByTestId('create-button'); + this.pageTitle = page.getByTestId('create-user-title'); + this.invitationEmailInfoAlert = page.getByTestId( + 'invitation-email-info-alert' + ); + this.acceptInvitationLink = page + .getByTestId('invitation-email-info-alert') + .getByRole('link'); + this.createUserSuccessAlert = page.getByTestId('create-user-success-alert'); + this.fieldError = page.locator('p[id$="-helper-text"]'); + } + + seed(seed) { + faker.seed(seed || 0); + } + + generateUser() { + return { + fullName: faker.person.fullName(), + email: faker.internet.email().toLowerCase(), + }; + } + + async expectCreateUserSuccessAlertToBeVisible() { + await expect(this.createUserSuccessAlert).toBeVisible(); + } +} diff --git a/packages/e2e-tests/fixtures/admin/delete-role-modal.js b/packages/e2e-tests/fixtures/admin/delete-role-modal.js new file mode 100644 index 0000000..0593c63 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/delete-role-modal.js @@ -0,0 +1,20 @@ +export class DeleteRoleModal { + screenshotPath = '/admin/delete-role-modal'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor (page) { + this.page = page; + this.modal = page.getByTestId('delete-role-modal'); + this.cancelButton = this.modal.getByTestId('confirmation-cancel-button'); + this.deleteButton = this.modal.getByTestId('confirmation-confirm-button'); + this.deleteAlert = this.modal.getByTestId('confirmation-dialog-error-alert'); + } + + async close () { + await this.page.click('body', { + position: { x: 10, y: 10 } + }); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/admin/delete-user-modal.js b/packages/e2e-tests/fixtures/admin/delete-user-modal.js new file mode 100644 index 0000000..8a4f33e --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/delete-user-modal.js @@ -0,0 +1,19 @@ +export class DeleteUserModal { + screenshotPath = '/admin/delete-modal'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor (page) { + this.page = page; + this.modal = page.getByTestId('delete-user-modal'); + this.cancelButton = this.modal.getByTestId('confirmation-cancel-button'); + this.deleteButton = this.modal.getByTestId('confirmation-confirm-button'); + } + + async close () { + await this.page.click('body', { + position: { x: 10, y: 10 } + }); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/admin/edit-role-page.js b/packages/e2e-tests/fixtures/admin/edit-role-page.js new file mode 100644 index 0000000..9c9fec6 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/edit-role-page.js @@ -0,0 +1,10 @@ +const { AdminCreateRolePage } = require('./create-role-page'); + +export class AdminEditRolePage extends AdminCreateRolePage { + constructor (page) { + super(page); + delete this.createButton; + this.updateButton = page.getByTestId('update-button'); + this.pageTitle = page.getByTestId('edit-role-title'); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/admin/edit-user-page.js b/packages/e2e-tests/fixtures/admin/edit-user-page.js new file mode 100644 index 0000000..d0ee043 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/edit-user-page.js @@ -0,0 +1,39 @@ +const { faker } = require('@faker-js/faker'); +const { AuthenticatedPage } = require('../authenticated-page'); + +faker.seed(9002); + +export class AdminEditUserPage extends AuthenticatedPage { + screenshot = '/admin/edit-user'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + this.fullNameInput = page.getByTestId('full-name-input'); + this.emailInput = page.getByTestId('email-input'); + this.roleInput = page.getByTestId('roleId-autocomplete'); + this.updateButton = page.getByTestId('update-button'); + this.pageTitle = page.getByTestId('edit-user-title'); + this.fieldError = page.locator('p[id$="-helper-text"]'); + } + + /** + * @param {string} fullName + */ + async waitForLoad(fullName) { + return await this.page.waitForFunction((fullName) => { + // eslint-disable-next-line no-undef + const el = document.querySelector("[data-test='full-name-input']"); + return el && el.value === fullName; + }, fullName); + } + + generateUser() { + return { + fullName: faker.person.fullName(), + email: faker.internet.email(), + }; + } +} diff --git a/packages/e2e-tests/fixtures/admin/index.js b/packages/e2e-tests/fixtures/admin/index.js new file mode 100644 index 0000000..db99cf3 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/index.js @@ -0,0 +1,43 @@ +const { AdminCreateUserPage } = require('./create-user-page'); +const { AdminEditUserPage } = require('./edit-user-page'); +const { AdminUsersPage } = require('./users-page'); + +const { AdminRolesPage } = require('./roles-page'); +const { AdminCreateRolePage } = require('./create-role-page'); +const { AdminEditRolePage } = require('./edit-role-page'); + +const { AdminApplicationsPage } = require('./applications-page'); +const { AdminApplicationSettingsPage } = require('./application-settings-page'); +const { + AdminApplicationOAuthClientsPage, +} = require('./application-oauth-clients-page'); + +export const adminFixtures = { + adminUsersPage: async ({ page }, use) => { + await use(new AdminUsersPage(page)); + }, + adminCreateUserPage: async ({ page }, use) => { + await use(new AdminCreateUserPage(page)); + }, + adminEditUserPage: async ({ page }, use) => { + await use(new AdminEditUserPage(page)); + }, + adminRolesPage: async ({ page }, use) => { + await use(new AdminRolesPage(page)); + }, + adminEditRolePage: async ({ page }, use) => { + await use(new AdminEditRolePage(page)); + }, + adminCreateRolePage: async ({ page }, use) => { + await use(new AdminCreateRolePage(page)); + }, + adminApplicationsPage: async ({ page }, use) => { + await use(new AdminApplicationsPage(page)); + }, + adminApplicationSettingsPage: async ({ page }, use) => { + await use(new AdminApplicationSettingsPage(page)); + }, + adminApplicationOAuthClientsPage: async ({ page }, use) => { + await use(new AdminApplicationOAuthClientsPage(page)); + }, +}; diff --git a/packages/e2e-tests/fixtures/admin/role-conditions-modal.js b/packages/e2e-tests/fixtures/admin/role-conditions-modal.js new file mode 100644 index 0000000..4e2c64f --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/role-conditions-modal.js @@ -0,0 +1,47 @@ +export class RoleConditionsModal { + + /** + * @param {import('@playwright/test').Page} page + * @param {('Connection'|'Execution'|'Flow')} subject + */ + constructor (page, subject) { + this.page = page; + this.modal = page.getByTestId(`${subject}-role-conditions-modal`); + this.modalBody = this.modal.getByTestId('role-conditions-modal-body'); + this.createCheckbox = this.modal.getByTestId( + 'isCreator-create-checkbox' + ).locator('input'); + this.readCheckbox = this.modal.getByTestId( + 'isCreator-read-checkbox' + ).locator('input'); + this.updateCheckbox = this.modal.getByTestId( + 'isCreator-update-checkbox' + ).locator('input'); + this.deleteCheckbox = this.modal.getByTestId( + 'isCreator-delete-checkbox' + ).locator('input'); + this.publishCheckbox = this.modal.getByTestId( + 'isCreator-publish-checkbox' + ).locator('input'); + this.applyButton = this.modal.getByTestId('confirmation-confirm-button'); + this.cancelButton = this.modal.getByTestId('confirmation-cancel-button'); + } + + async getAvailableConditions () { + let conditions = {}; + const actions = ['create', 'read', 'update', 'delete', 'publish']; + for (let action of actions) { + const locator = this[`${action}Checkbox`]; + if (locator && await locator.count() > 0) { + conditions[action] = locator; + } + } + return conditions; + } + + async close () { + await this.page.click('body', { + position: { x: 10, y: 10 } + }); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/admin/roles-page.js b/packages/e2e-tests/fixtures/admin/roles-page.js new file mode 100644 index 0000000..e46279f --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/roles-page.js @@ -0,0 +1,81 @@ +const { AuthenticatedPage } = require('../authenticated-page'); +const { DeleteRoleModal } = require('./delete-role-modal'); + +export class AdminRolesPage extends AuthenticatedPage { + screenshotPath = '/admin-roles'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + this.roleDrawerLink = page.getByTestId('roles-drawer-link'); + this.createRoleButton = page.getByTestId('create-role'); + this.deleteRoleModal = new DeleteRoleModal(page); + this.roleRow = page.getByTestId('role-row'); + this.rolesLoader = page.getByTestId('roles-list-loader'); + this.pageTitle = page.getByTestId('roles-page-title'); + } + + /** + * + * @param {boolean} isMobile - navigation on smaller devices requires the + * user to open up the drawer menu + */ + async navigateTo(isMobile = false) { + await this.profileMenuButton.click(); + await this.adminMenuItem.click(); + if (isMobile) { + await this.drawerMenuButton.click(); + } + await this.roleDrawerLink.click(); + await this.isMounted(); + await this.rolesLoader.waitFor({ + state: 'detached', + }); + } + + /** + * @param {string} name + */ + async getRoleRowByName(name) { + await this.rolesLoader.waitFor({ + state: 'detached', + }); + return this.roleRow.filter({ + has: this.page.getByTestId('role-name').getByText(name, { exact: true }), + }); + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async getRowData(row) { + return { + role: await row.getByTestId('role-name').textContent(), + description: await row.getByTestId('role-description').textContent(), + canEdit: await row.getByTestId('role-edit').isEnabled(), + canDelete: await row.getByTestId('role-delete').isEnabled(), + }; + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async clickEditRole(row) { + await row.getByTestId('role-edit').click(); + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async clickDeleteRole(row) { + await row.getByTestId('role-delete').click(); + return this.deleteRoleModal; + } + + async editRole(subject) { + const row = await this.getRoleRowByName(subject); + await this.clickEditRole(row); + } +} diff --git a/packages/e2e-tests/fixtures/admin/users-page.js b/packages/e2e-tests/fixtures/admin/users-page.js new file mode 100644 index 0000000..6b7f626 --- /dev/null +++ b/packages/e2e-tests/fixtures/admin/users-page.js @@ -0,0 +1,136 @@ +const { faker } = require('@faker-js/faker'); +const { AuthenticatedPage } = require('../authenticated-page'); +const { DeleteUserModal } = require('./delete-user-modal'); + +faker.seed(9001); + +export class AdminUsersPage extends AuthenticatedPage { + screenshotPath = '/admin'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + this.createUserButton = page.getByTestId('create-user'); + this.userRow = page.getByTestId('user-row'); + this.deleteUserModal = new DeleteUserModal(page); + this.firstPageButton = page.getByTestId('first-page-button'); + this.previousPageButton = page.getByTestId('previous-page-button'); + this.nextPageButton = page.getByTestId('next-page-button'); + this.lastPageButton = page.getByTestId('last-page-button'); + this.usersLoader = page.getByTestId('users-list-loader'); + this.pageTitle = page.getByTestId('users-page-title'); + } + + async navigateTo() { + await this.profileMenuButton.click(); + await this.adminMenuItem.click(); + await this.isMounted(); + if (await this.usersLoader.isVisible()) { + await this.usersLoader.waitFor({ + state: 'detached', + }); + } + } + + /** + * @param {string} email + */ + async getUserRowByEmail(email) { + return this.userRow.filter({ + has: this.page.getByTestId('user-email').filter({ + hasText: email, + }), + }); + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async getRowData(row) { + return { + fullName: await row.getByTestId('user-full-name').textContent(), + email: await row.getByTestId('user-email').textContent(), + role: await row.getByTestId('user-role').textContent(), + }; + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async clickEditUser(row) { + await row.getByTestId('user-edit').click(); + } + + /** + * @param {import('@playwright/test').Locator} row + */ + async clickDeleteUser(row) { + await row.getByTestId('delete-button').click(); + return this.deleteUserModal; + } + + /** + * @param {string} email + * @returns {import('@playwright/test').Locator | null} + */ + async findUserPageWithEmail(email) { + if (await this.usersLoader.isVisible()) { + await this.usersLoader.waitFor({ + state: 'detached', + }); + } + // start at the first page + const firstPageDisabled = await this.firstPageButton.isDisabled(); + if (!firstPageDisabled) { + await this.firstPageButton.click(); + } + + // eslint-disable-next-line no-constant-condition + while (true) { + if (await this.usersLoader.isVisible()) { + await this.usersLoader.waitFor({ + state: 'detached', + }); + } + const rowLocator = await this.getUserRowByEmail(email); + if ((await rowLocator.count()) === 1) { + return rowLocator; + } + if (await this.nextPageButton.isDisabled()) { + return null; + } else { + await this.nextPageButton.click(); + } + } + } + + async getTotalRows() { + return await this.page.evaluate(() => { + // eslint-disable-next-line no-undef + const node = document.querySelector('[data-total-count]'); + if (node) { + const count = Number(node.dataset.totalCount); + if (!isNaN(count)) { + return count; + } + } + return 0; + }); + } + + async getRowsPerPage() { + return await this.page.evaluate(() => { + // eslint-disable-next-line no-undef + const node = document.querySelector('[data-rows-per-page]'); + if (node) { + const count = Number(node.dataset.rowsPerPage); + if (!isNaN(count)) { + return count; + } + } + return 0; + }); + } +} diff --git a/packages/e2e-tests/fixtures/applications-modal.js b/packages/e2e-tests/fixtures/applications-modal.js new file mode 100644 index 0000000..f9bab96 --- /dev/null +++ b/packages/e2e-tests/fixtures/applications-modal.js @@ -0,0 +1,34 @@ +const { GithubPage } = require('./apps/github/github-page'); +const { BasePage } = require('./base-page'); + +export class ApplicationsModal extends BasePage { + + applications = { + github: GithubPage + }; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor (page) { + super(page); + this.modal = page.getByTestId('add-app-connection-dialog'); + this.searchInput = this.modal.getByTestId('search-for-app-text-field'); + this.appListItem = this.modal.getByTestId('app-list-item'); + this.appLoader = this.modal.getByTestId('search-for-app-loader'); + } + + /** + * @param string link + */ + async selectLink (link) { + if (this.applications[link] === undefined) { + throw { + message: `Unknown link "${link}" passed to ApplicationsModal.selectLink` + }; + } + await this.searchInput.fill(link); + await this.appListItem.first().click(); + return new this.applications[link](this.page); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/applications-page.js b/packages/e2e-tests/fixtures/applications-page.js new file mode 100644 index 0000000..bad839d --- /dev/null +++ b/packages/e2e-tests/fixtures/applications-page.js @@ -0,0 +1,21 @@ +const { ApplicationsModal } = require('./applications-modal'); +const { AuthenticatedPage } = require('./authenticated-page'); + +export class ApplicationsPage extends AuthenticatedPage { + screenshotPath = '/applications'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.drawerLink = this.page.getByTestId('apps-page-drawer-link'); + this.addConnectionButton = this.page.getByTestId('add-connection-button'); + } + + async openAddConnectionModal () { + await this.addConnectionButton.click(); + return new ApplicationsModal(this.page); + } +} diff --git a/packages/e2e-tests/fixtures/apps/github/add-github-connection-modal.js b/packages/e2e-tests/fixtures/apps/github/add-github-connection-modal.js new file mode 100644 index 0000000..f5cd638 --- /dev/null +++ b/packages/e2e-tests/fixtures/apps/github/add-github-connection-modal.js @@ -0,0 +1,49 @@ +import { GithubPopup } from './github-popup'; + +const { BasePage } = require('../../base-page'); + +export class AddGithubConnectionModal extends BasePage { + + /** + * @param {import('@playwright/test').Page} page + */ + constructor (page) { + super(page); + this.modal = page.getByTestId('add-app-connection-dialog'); + this.oauthRedirectInput = page.getByTestId('oAuthRedirectUrl-text'); + this.clientIdInput = page.getByTestId('consumerKey-text'); + this.clientIdSecretInput = page.getByTestId('consumerSecret-text'); + this.submitButton = page.getByTestId('create-connection-button'); + } + + async visible () { + return await this.modal.isVisible(); + } + + async inputForm () { + await connectionModal.clientIdInput.fill( + process.env.GITHUB_CLIENT_ID + ); + await connectionModal.clientIdSecretInput.fill( + process.env.GITHUB_CLIENT_SECRET + ); + } + + /** + * @returns {import('@playwright/test').Page} + */ + async submit () { + const popupPromise = this.page.waitForEvent('popup'); + await this.submitButton.click(); + const popup = await popupPromise; + await popup.bringToFront(); + return popup; + } + + /** + * @param {import('@playwright/test').Page} page + */ + async handlePopup (page) { + return await GithubPopup.handle(page); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/apps/github/github-page.js b/packages/e2e-tests/fixtures/apps/github/github-page.js new file mode 100644 index 0000000..d826c0d --- /dev/null +++ b/packages/e2e-tests/fixtures/apps/github/github-page.js @@ -0,0 +1,66 @@ +const { BasePage } = require('../../base-page'); +const { AddGithubConnectionModal } = require('./add-github-connection-modal'); +const { expect } = require('@playwright/test'); + +export class GithubPage extends BasePage { + + constructor (page) { + super(page); + this.addConnectionButton = page.getByTestId('add-connection-button'); + this.connectionsTab = page.getByTestId('connections-tab'); + this.flowsTab = page.getByTestId('flows-tab'); + this.connectionRows = page.getByTestId('connection-row'); + this.flowRows = page.getByTestId('flow-row'); + this.firstConnectionButton = page.getByTestId('connections-no-results'); + this.firstFlowButton = page.getByTestId('flows-no-results'); + this.addConnectionModal = new AddGithubConnectionModal(page); + } + + async goto () { + await this.page.goto('/app/github/connections'); + } + + async openConnectionModal () { + await this.addConnectionButton.click(); + await expect(this.addConnectionButton.modal).toBeVisible(); + return this.addConnectionModal; + } + + async flowsVisible () { + return this.page.url() === await this.flowsTab.getAttribute('href'); + } + + async connectionsVisible () { + return this.page.url() === await this.connectionsTab.getAttribute('href'); + } + + async hasFlows () { + if (!(await this.flowsVisible())) { + await this.flowsTab.click(); + await expect(this.flowsTab).toBeVisible(); + } + return await this.flowRows.count() > 0; + } + + async hasConnections () { + if (!(await this.connectionsVisible())) { + await this.connectionsTab.click(); + await expect(this.connectionsTab).toBeVisible(); + } + return await this.connectionRows.count() > 0; + } +} + +/** + * + * @param {import('@playwright/test').Page} page + */ +export async function initGithubConnection (page) { + // assumes already logged in + const githubPage = new GithubPage(page); + await githubPage.goto(); + const modal = await githubPage.openConnectionModal(); + await modal.inputForm(); + const popup = await modal.submit(); + await modal.handlePopup(popup); +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/apps/github/github-popup.js b/packages/e2e-tests/fixtures/apps/github/github-popup.js new file mode 100644 index 0000000..f1c8e2f --- /dev/null +++ b/packages/e2e-tests/fixtures/apps/github/github-popup.js @@ -0,0 +1,93 @@ +const { BasePage } = require('../../base-page'); +const { expect } = require('@playwright/test'); + +export class GithubPopup extends BasePage { + + /** + * @param {import('@playwright/test').Page} page + */ + static async handle (page) { + const popup = new GithubPopup(page); + return await popup.handleAuthFlow(); + } + + getPathname () { + const url = this.page.url(); + try { + return new URL(url).pathname; + } catch (e) { + return new URL(`https://github.com/${url}`).pathname; + } + } + + async handleAuthFlow () { + if (this.getPathname() === '/login') { + await this.handleLogin(); + } + if (this.page.isClosed()) { return; } + if (this.getPathname() === '/login/oauth/authorize') { + await this.handleAuthorize(); + } + } + + async handleLogin () { + const loginInput = this.page.getByLabel('Username or email address'); + loginInput.click(); + await loginInput.fill(process.env.GITHUB_USERNAME); + const passwordInput = this.page.getByLabel('Password'); + passwordInput.click(); + await passwordInput.fill(process.env.GITHUB_PASSWORD); + await this.page.getByRole('button', { name: 'Sign in' }).click(); + // await this.page.waitForTimeout(2000); + if (this.page.isClosed()) { + return; + } + // await this.page.waitForLoadState('networkidle', 30000); + this.page.waitForEvent('load'); + if (this.page.isClosed()) { + return; + } + await this.page.waitForURL(function (url) { + const u = new URL(url); + return ( + u.pathname === '/login/oauth/authorize' + ) && u.searchParams.get('client_id'); + }); + } + + async handleAuthorize () { + if (this.page.isClosed()) { return; } + const authorizeButton = this.page.getByRole( + 'button', + { name: 'Authorize' } + ); + await this.page.waitForEvent('load'); + await authorizeButton.click(); + await this.page.waitForURL(function (url) { + const u = new URL(url); + return ( + u.pathname === '/login/oauth/authorize' + ) && ( + u.searchParams.get('client_id') === null + ); + }); + const passwordInput = this.page.getByLabel('Password'); + if (await passwordInput.isVisible()) { + await passwordInput.fill(process.env.GITHUB_PASSWORD); + const submitButton = this.page + .getByRole('button') + .filter({ hasText: /confirm|submit|enter|go|sign in/gmi }); + if (await submitButton.isVisible()) { + submitButton.waitFor(); + await expect(submitButton).toBeEnabled(); + await submitButton.click(); + } else { + throw { + page: this.page, + error: 'Could not find submit button for confirming user account' + }; + } + } + await this.page.waitForEvent('close'); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/apps/mattermost/add-mattermost-connection-modal.js b/packages/e2e-tests/fixtures/apps/mattermost/add-mattermost-connection-modal.js new file mode 100644 index 0000000..9bf99ca --- /dev/null +++ b/packages/e2e-tests/fixtures/apps/mattermost/add-mattermost-connection-modal.js @@ -0,0 +1,25 @@ +const { BasePage } = require('../../base-page'); + +export class AddMattermostConnectionModal extends BasePage { + + /** + * @param {import('@playwright/test').Page} page + */ + constructor (page) { + super(page); + this.clientIdInput = page.getByTestId('clientId-text'); + this.clientIdSecretInput = page.getByTestId('clientSecret-text'); + this.instanceUrlInput = page.getByTestId("instanceUrl-text"); + this.submitButton = page.getByTestId('create-connection-button'); + } + + async fillConnectionForm() { + await this.instanceUrlInput.fill('https://mattermost.com'); + await this.clientIdInput.fill('aaa'); + await this.clientIdSecretInput.fill('bbb'); + } + + async submitConnectionForm() { + await this.submitButton.click(); + } +} \ No newline at end of file diff --git a/packages/e2e-tests/fixtures/authenticated-page.js b/packages/e2e-tests/fixtures/authenticated-page.js new file mode 100644 index 0000000..6e81d12 --- /dev/null +++ b/packages/e2e-tests/fixtures/authenticated-page.js @@ -0,0 +1,27 @@ +const { BasePage } = require('./base-page'); + +export class AuthenticatedPage extends BasePage { + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.profileMenuButton = this.page.getByTestId('profile-menu-button'); + this.logoutMenuItem = this.page.getByTestId('logout-item'); + this.adminMenuItem = this.page.getByRole('menuitem', { name: 'Admin' }); + this.userInterfaceDrawerItem = this.page.getByTestId( + 'user-interface-drawer-link' + ); + this.appBar = this.page.getByTestId('app-bar'); + this.drawerMenuButton = this.page.getByTestId('drawer-menu-button'); + this.goToDashboardButton = this.page.getByTestId('go-back-drawer-link'); + this.typographyLogo = this.page.getByTestId('typography-logo'); + this.customLogo = this.page.getByTestId('custom-logo'); + } + + async logout() { + await this.profileMenuButton.click(); + await this.logoutMenuItem.click(); + } +} diff --git a/packages/e2e-tests/fixtures/base-page.js b/packages/e2e-tests/fixtures/base-page.js new file mode 100644 index 0000000..ade0343 --- /dev/null +++ b/packages/e2e-tests/fixtures/base-page.js @@ -0,0 +1,96 @@ +const path = require('node:path'); + +/** + * @typedef {( + * 'default' | 'success' | 'warning' | 'error' | 'info' + * )} SnackbarVariant - Snackbar variant types in notistack/v3, see https://notistack.com/api-reference + */ + +export class BasePage { + screenshotPath = '/'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + this.page = page; + this.snackbar = page.locator('*[data-test^="snackbar"]'); + this.pageTitle = this.page.getByTestId('page-title'); + } + + /** + * Finds the latest snackbar message and extracts relevant data + * @param {string | undefined} testId + * @returns {( + * null | { + * variant: SnackbarVariant, + * text: string, + * dataset: { [key: string]: string } + * } + * )} + */ + async getSnackbarData(testId) { + if (!testId) { + testId = 'snackbar'; + } + const snack = this.page.getByTestId(testId); + return { + variant: await snack.getAttribute('data-snackbar-variant'), + text: await snack.evaluate((node) => node.innerText), + dataset: await snack.evaluate((node) => { + function getChildren(n) { + return [n].concat( + ...Array.from(n.children).map((c) => getChildren(c)) + ); + } + const datasets = getChildren(node).map((n) => + Object.assign({}, n.dataset) + ); + return Object.assign({}, ...datasets); + }), + }; + } + + async closeSnackbar() { + await this.snackbar.click(); + } + + async closeSnackbarAndWaitUntilDetached() { + const snackbar = await this.snackbar; + await snackbar.click(); + await snackbar.waitFor({ state: 'detached' }); + } + + /** + * Closes all snackbars, should be replaced later + */ + async closeAllSnackbars() { + const snackbars = await this.snackbar.all(); + for (const snackbar of snackbars) { + await snackbar.click(); + } + for (const snackbar of snackbars) { + await snackbar.waitFor({ state: 'detached' }); + } + } + + async clickAway() { + await this.page.locator('body').click({ position: { x: 0, y: 0 } }); + } + + async screenshot(options = {}) { + const { path: plainPath, ...restOptions } = options; + + const computedPath = path.join( + 'output/screenshots', + this.screenshotPath, + plainPath + ); + + return await this.page.screenshot({ path: computedPath, ...restOptions }); + } + + async isMounted() { + await this.pageTitle.waitFor({ state: 'attached' }); + } +} diff --git a/packages/e2e-tests/fixtures/connections-page.js b/packages/e2e-tests/fixtures/connections-page.js new file mode 100644 index 0000000..a0ef8c4 --- /dev/null +++ b/packages/e2e-tests/fixtures/connections-page.js @@ -0,0 +1,9 @@ +const { AuthenticatedPage } = require('./authenticated-page'); + +export class ConnectionsPage extends AuthenticatedPage { + screenshotPath = '/connections'; + + async clickAddConnectionButton() { + await this.page.getByTestId('add-connection-button').click(); + } +} diff --git a/packages/e2e-tests/fixtures/executions-page.js b/packages/e2e-tests/fixtures/executions-page.js new file mode 100644 index 0000000..41b673e --- /dev/null +++ b/packages/e2e-tests/fixtures/executions-page.js @@ -0,0 +1,5 @@ +const { AuthenticatedPage } = require('./authenticated-page'); + +export class ExecutionsPage extends AuthenticatedPage { + screenshotPath = '/executions'; +} diff --git a/packages/e2e-tests/fixtures/flow-editor-page.js b/packages/e2e-tests/fixtures/flow-editor-page.js new file mode 100644 index 0000000..af321b5 --- /dev/null +++ b/packages/e2e-tests/fixtures/flow-editor-page.js @@ -0,0 +1,91 @@ +const { AuthenticatedPage } = require('./authenticated-page'); +const { expect } = require('@playwright/test'); +const axios = require('axios'); + +export class FlowEditorPage extends AuthenticatedPage { + screenshotPath = '/flow-editor'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.appAutocomplete = this.page.getByTestId('choose-app-autocomplete'); + this.eventAutocomplete = this.page.getByTestId('choose-event-autocomplete'); + this.continueButton = this.page.getByTestId('flow-substep-continue-button'); + this.testAndContinueButton = this.page.getByText('Test & Continue'); + this.connectionAutocomplete = this.page.getByTestId( + 'choose-connection-autocomplete' + ); + this.addNewConnectionItem = this.page.getByText('Add new connection'); + this.testOutput = this.page.getByTestId('flow-test-substep-output'); + this.hasNoOutput = this.page.getByTestId('flow-test-substep-no-output'); + this.unpublishFlowButton = this.page.getByTestId('unpublish-flow-button'); + this.publishFlowButton = this.page.getByTestId('publish-flow-button'); + this.infoSnackbar = this.page.getByTestId('flow-cannot-edit-info-snackbar'); + this.trigger = this.page.getByLabel('Trigger on weekends?'); + this.stepCircularLoader = this.page.getByTestId('step-circular-loader'); + this.flowName = this.page.getByTestId('editableTypography'); + this.flowNameInput = this.page + .getByTestId('editableTypographyInput') + .locator('input'); + + this.flowStep = this.page.getByTestId('flow-step'); + } + + async createWebhookTrigger(workSynchronously) { + await this.appAutocomplete.click(); + await this.page.getByRole('option', { name: 'Webhook' }).click(); + + await expect(this.eventAutocomplete).toBeVisible(); + await this.eventAutocomplete.click(); + await this.page.getByRole('option', { name: 'Catch raw webhook' }).click(); + await this.continueButton.click(); + await this.page + .getByTestId('parameters.workSynchronously-autocomplete') + .click(); + await this.page + .getByRole('option', { name: workSynchronously ? 'Yes' : 'No' }) + .click(); + await this.continueButton.click(); + + const webhookUrl = this.page.locator('input[name="webhookUrl"]'); + if (workSynchronously) { + await expect(webhookUrl).toHaveValue(/sync/); + } else { + await expect(webhookUrl).not.toHaveValue(/sync/); + } + + const triggerResponse = await axios.get(await webhookUrl.inputValue()); + await expect(triggerResponse.status).toBe(204); + + await expect(this.testOutput).not.toBeVisible(); + await this.testAndContinueButton.click(); + await expect(this.testOutput).toBeVisible(); + await expect(this.hasNoOutput).not.toBeVisible(); + await this.continueButton.click(); + + return await webhookUrl.inputValue(); + } + + async chooseAppAndEvent(appName, eventName) { + await expect(this.appAutocomplete).toHaveCount(1); + await this.appAutocomplete.click(); + await this.page.getByRole('option', { name: appName }).click(); + await expect(this.eventAutocomplete).toBeVisible(); + await this.eventAutocomplete.click(); + await Promise.all([ + this.page.waitForResponse(resp => /(apps\/.*\/actions\/.*\/substeps)/.test(resp.url()) && resp.status() === 200), + this.page.getByRole('option', { name: eventName }).click(), + ]); + await this.continueButton.click(); + } + + async testAndContinue() { + await expect(this.continueButton).toHaveCount(1); + await this.continueButton.click(); + await expect(this.testOutput).toBeVisible(); + await this.continueButton.click(); + } +} diff --git a/packages/e2e-tests/fixtures/index.js b/packages/e2e-tests/fixtures/index.js new file mode 100644 index 0000000..069c2a7 --- /dev/null +++ b/packages/e2e-tests/fixtures/index.js @@ -0,0 +1,74 @@ +const { test, expect } = require('@playwright/test'); +const { ApplicationsPage } = require('./applications-page'); +const { ConnectionsPage } = require('./connections-page'); +const { ExecutionsPage } = require('./executions-page'); +const { FlowEditorPage } = require('./flow-editor-page'); +const { UserInterfacePage } = require('./user-interface-page'); +const { LoginPage } = require('./login-page'); +const { AcceptInvitation } = require('./accept-invitation-page'); +const { adminFixtures } = require('./admin'); +const { AdminSetupPage } = require('./admin-setup-page'); +const { AdminCreateUserPage } = require('./admin/create-user-page'); + +exports.test = test.extend({ + page: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await loginPage.login(); + + await expect(loginPage.loginButton).not.toBeVisible(); + await expect(page).toHaveURL('/flows'); + + await use(page); + }, + applicationsPage: async ({ page }, use) => { + await use(new ApplicationsPage(page)); + }, + connectionsPage: async ({ page }, use) => { + await use(new ConnectionsPage(page)); + }, + executionsPage: async ({ page }, use) => { + await use(new ExecutionsPage(page)); + }, + flowEditorPage: async ({ page }, use) => { + await use(new FlowEditorPage(page)); + }, + userInterfacePage: async ({ page }, use) => { + await use(new UserInterfacePage(page)); + }, + ...adminFixtures, +}); + +exports.publicTest = test.extend({ + page: async ({ page }, use) => { + await use(page); + }, + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + + await loginPage.open(); + + await use(loginPage); + }, + acceptInvitationPage: async ({ page }, use) => { + const acceptInvitationPage = new AcceptInvitation(page); + await use(acceptInvitationPage); + }, + adminSetupPage: async ({ page }, use) => { + const adminSetupPage = new AdminSetupPage(page); + await use(adminSetupPage); + }, + adminCreateUserPage: async ({ page }, use) => { + const adminCreateUserPage = new AdminCreateUserPage(page); + await use(adminCreateUserPage); + }, +}); + +expect.extend({ + toBeClickableLink: async (locator) => { + await expect(locator).not.toHaveAttribute('aria-disabled', 'true'); + + return { pass: true }; + }, +}); + +exports.expect = expect; diff --git a/packages/e2e-tests/fixtures/login-page.js b/packages/e2e-tests/fixtures/login-page.js new file mode 100644 index 0000000..a2e9128 --- /dev/null +++ b/packages/e2e-tests/fixtures/login-page.js @@ -0,0 +1,46 @@ +const { BasePage } = require('./base-page'); + +export class LoginPage extends BasePage { + path = '/login'; + static defaultEmail = process.env.LOGIN_EMAIL; + static defaultPassword = process.env.LOGIN_PASSWORD; + + static setDefaultLogin(email, password) { + this.defaultEmail = email; + this.defaultPassword = password; + } + + static resetDefaultLogin() { + this.defaultEmail = process.env.LOGIN_EMAIL; + this.defaultPassword = process.env.LOGIN_PASSWORD; + } + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.page = page; + this.emailTextField = this.page.getByTestId('email-text-field'); + this.passwordTextField = this.page.getByTestId('password-text-field'); + this.loginButton = this.page.getByTestId('login-button'); + this.pageTitle = this.page.getByTestId('login-form-title'); + } + + async open() { + return await this.page.goto(this.path); + } + + async login( + email = LoginPage.defaultEmail, + password = LoginPage.defaultPassword + ) { + await this.page.goto(this.path); + await this.emailTextField.waitFor({ state: 'visible' }); + await this.emailTextField.fill(email); + await this.passwordTextField.fill(password); + + await this.loginButton.click(); + } +} diff --git a/packages/e2e-tests/fixtures/my-profile-page.js b/packages/e2e-tests/fixtures/my-profile-page.js new file mode 100644 index 0000000..1bce7c6 --- /dev/null +++ b/packages/e2e-tests/fixtures/my-profile-page.js @@ -0,0 +1,23 @@ +const { AuthenticatedPage } = require('./authenticated-page'); + +export class MyProfilePage extends AuthenticatedPage { + constructor(page) { + super(page); + + this.fullName = this.page.locator('[name="fullName"]'); + this.email = this.page.locator('[name="email"]'); + this.currentPassword = this.page.locator('[name="currentPassword"]'); + this.newPassword = this.page.locator('[name="password"]'); + this.passwordConfirmation = this.page.locator('[name="confirmPassword"]'); + this.updateProfileButton = this.page.getByTestId('update-profile-button'); + this.updatePasswordButton = this.page.getByTestId('update-password-button'); + this.settingsMenuItem = this.page.getByRole('menuitem', { + name: 'Settings', + }); + } + + async navigateTo() { + await this.profileMenuButton.click(); + await this.settingsMenuItem.click(); + } +} diff --git a/packages/e2e-tests/fixtures/postgres-config.js b/packages/e2e-tests/fixtures/postgres-config.js new file mode 100644 index 0000000..8d82478 --- /dev/null +++ b/packages/e2e-tests/fixtures/postgres-config.js @@ -0,0 +1,14 @@ +const { Pool } = require('pg'); + +const pool = new Pool({ + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + host: process.env.POSTGRES_HOST, + user: process.env.POSTGRES_USERNAME, + port: process.env.POSTGRES_PORT, + password: process.env.POSTGRES_PASSWORD, + database: process.env.POSTGRES_DATABASE +}); + +exports.pgPool = pool; diff --git a/packages/e2e-tests/fixtures/user-interface-page.js b/packages/e2e-tests/fixtures/user-interface-page.js new file mode 100644 index 0000000..6342af3 --- /dev/null +++ b/packages/e2e-tests/fixtures/user-interface-page.js @@ -0,0 +1,52 @@ +const { AuthenticatedPage } = require('./authenticated-page'); + +export class UserInterfacePage extends AuthenticatedPage { + screenshotPath = '/user-interface'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.flowRowCardActionArea = this.page + .getByTestId('flow-row') + .first() + .getByTestId('card-action-area'); + this.updateButton = this.page.getByTestId('update-button'); + this.primaryMainColorInput = this.page + .getByTestId('primary-main-color-input') + .getByTestId('color-text-field'); + this.primaryDarkColorInput = this.page + .getByTestId('primary-dark-color-input') + .getByTestId('color-text-field'); + this.primaryLightColorInput = this.page + .getByTestId('primary-light-color-input') + .getByTestId('color-text-field'); + this.logoSvgCodeInput = this.page.getByTestId('logo-svg-data-text-field'); + this.primaryMainColorButton = this.page + .getByTestId('primary-main-color-input') + .getByTestId('color-button'); + this.primaryDarkColorButton = this.page + .getByTestId('primary-dark-color-input') + .getByTestId('color-button'); + this.primaryLightColorButton = this.page + .getByTestId('primary-light-color-input') + .getByTestId('color-button'); + } + + hexToRgb(hexColor) { + hexColor = hexColor.replace('#', ''); + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + + return `rgb(${r}, ${g}, ${b})`; + } + + encodeSVG(svgCode) { + const encoded = encodeURIComponent(svgCode); + + return `data:image/svg+xml;utf8,${encoded}`; + } +} diff --git a/packages/e2e-tests/helpers/auth-api-helper.js b/packages/e2e-tests/helpers/auth-api-helper.js new file mode 100644 index 0000000..f8067ed --- /dev/null +++ b/packages/e2e-tests/helpers/auth-api-helper.js @@ -0,0 +1,16 @@ +const { expect } = require('../fixtures/index'); + +export const getToken = async (apiRequest) => { + const tokenResponse = await apiRequest.post( + `${process.env.BACKEND_APP_URL}/api/v1/access-tokens`, + { + data: { + email: process.env.LOGIN_EMAIL, + password: process.env.LOGIN_PASSWORD, + }, + } + ); + await expect(tokenResponse.status()).toBe(200); + + return await tokenResponse.json(); +}; diff --git a/packages/e2e-tests/helpers/db-helpers.js b/packages/e2e-tests/helpers/db-helpers.js new file mode 100644 index 0000000..6ba0bb6 --- /dev/null +++ b/packages/e2e-tests/helpers/db-helpers.js @@ -0,0 +1,32 @@ +const { expect } = require('../fixtures/index'); +const { pgPool } = require('../fixtures/postgres-config'); + +export const insertAppConnection = async (appName) => { + const queryUser = { + text: 'SELECT * FROM users WHERE email = $1', + values: [process.env.LOGIN_EMAIL], + }; + + try { + const queryUserResult = await pgPool.query(queryUser); + expect(queryUserResult.rowCount).toEqual(1); + + const createConnection = { + text: 'INSERT INTO connections (key, data, user_id, verified, draft) VALUES ($1, $2, $3, $4, $5)', + values: [ + appName, + 'U2FsdGVkX1+cAtdHwLiuRL4DaK/T1aljeeKyPMmtWK0AmAIsKhYwQiuyQCYJO3mdZ31z73hqF2Y+yj2Kn2/IIpLRqCxB2sC0rCDCZyolzOZ290YcBXSzYRzRUxhoOcZEtwYDKsy8AHygKK/tkj9uv9k6wOe1LjipNik4VmRhKjEYizzjLrJpbeU1oY+qW0GBpPYomFTeNf+MejSSmsUYyYJ8+E/4GeEfaonvsTSwMT7AId98Lck6Vy4wrfgpm7sZZ8xU15/HqXZNc8UCo2iTdw45xj/Oov9+brX4WUASFPG8aYrK8dl/EdaOvr89P8uIofbSNZ25GjJvVF5ymarrPkTZ7djjJXchzpwBY+7GTJfs3funR/vIk0Hq95jgOFFP1liZyqTXSa49ojG3hzojRQ==', + queryUserResult.rows[0].id, + 'true', + 'false', + ], + }; + + const createConnectionResult = await pgPool.query(createConnection); + expect(createConnectionResult.rowCount).toBe(1); + expect(createConnectionResult.command).toBe('INSERT'); + } catch (err) { + console.error(err.message); + throw err; + } +}; diff --git a/packages/e2e-tests/helpers/flow-api-helper.js b/packages/e2e-tests/helpers/flow-api-helper.js new file mode 100644 index 0000000..525274a --- /dev/null +++ b/packages/e2e-tests/helpers/flow-api-helper.js @@ -0,0 +1,69 @@ +const { expect } = require('../fixtures/index'); + +export const createFlow = async (request, token) => { + const response = await request.post( + `${process.env.BACKEND_APP_URL}/api/v1/flows`, + { headers: { Authorization: token } } + ); + await expect(response.status()).toBe(201); + return await response.json(); +}; + +export const getFlow = async (request, token, flowId) => { + const response = await request.get( + `${process.env.BACKEND_APP_URL}/api/v1/flows/${flowId}`, + { headers: { Authorization: token } } + ); + await expect(response.status()).toBe(200); + return await response.json(); +}; + +export const updateFlowName = async (request, token, flowId) => { + const updateFlowNameResponse = await request.patch( + `${process.env.BACKEND_APP_URL}/api/v1/flows/${flowId}`, + { + headers: { Authorization: token }, + data: { name: flowId }, + } + ); + await expect(updateFlowNameResponse.status()).toBe(200); +}; + +export const updateFlowStep = async (request, token, stepId, requestBody) => { + const updateTriggerStepResponse = await request.patch( + `${process.env.BACKEND_APP_URL}/api/v1/steps/${stepId}`, + { + headers: { Authorization: token }, + data: requestBody, + } + ); + await expect(updateTriggerStepResponse.status()).toBe(200); + return await updateTriggerStepResponse.json(); +}; + +export const testStep = async (request, token, stepId) => { + const testTriggerStepResponse = await request.post( + `${process.env.BACKEND_APP_URL}/api/v1/steps/${stepId}/test`, + { + headers: { Authorization: token }, + } + ); + await expect(testTriggerStepResponse.status()).toBe(200); +}; + +export const publishFlow = async (request, token, flowId) => { + const publishFlowResponse = await request.patch( + `${process.env.BACKEND_APP_URL}/api/v1/flows/${flowId}/status`, + { + headers: { Authorization: token }, + data: { active: true }, + } + ); + await expect(publishFlowResponse.status()).toBe(200); + return publishFlowResponse.json(); +}; + +export const triggerFlow = async (request, url) => { + const triggerFlowResponse = await request.get(url); + await expect(triggerFlowResponse.status()).toBe(204); +}; diff --git a/packages/e2e-tests/helpers/user-api-helper.js b/packages/e2e-tests/helpers/user-api-helper.js new file mode 100644 index 0000000..57d96e7 --- /dev/null +++ b/packages/e2e-tests/helpers/user-api-helper.js @@ -0,0 +1,24 @@ +const { expect } = require('../fixtures/index'); + +export const addUser = async (apiRequest, token, request) => { + const addUserResponse = await apiRequest.post( + `${process.env.BACKEND_APP_URL}/api/v1/admin/users`, + { + headers: { Authorization: token }, + data: request, + } + ); + await expect(addUserResponse.status()).toBe(201); + + return await addUserResponse.json(); +}; + +export const acceptInvitation = async (apiRequest, request) => { + const acceptInvitationResponse = await apiRequest.post( + `${process.env.BACKEND_APP_URL}/api/v1/users/invitation`, + { + data: request, + } + ); + await expect(acceptInvitationResponse.status()).toBe(204); +}; diff --git a/packages/e2e-tests/knexfile.js b/packages/e2e-tests/knexfile.js new file mode 100644 index 0000000..66e5451 --- /dev/null +++ b/packages/e2e-tests/knexfile.js @@ -0,0 +1,27 @@ +import { knexSnakeCaseMappers } from 'objection'; + +const fileExtension = 'js'; + +const knexConfig = { + client: 'pg', + connection: { + host: process.env.POSTGRES_HOST, + user: process.env.POSTGRES_USERNAME, + port: process.env.POSTGRES_PORT, + password: process.env.POSTGRES_PASSWORD, + database: process.env.POSTGRES_DATABASE, + }, + searchPath: ['public'], + pool: { min: 0, max: 20 }, + migrations: { + directory: '../../packages/backend/src/db/migrations/', + extension: fileExtension, + loadExtensions: [`.${fileExtension}`], + }, + seeds: { + directory: '../../packages/backend/src/db/seeds/', + }, + ...(process.env.APP_ENV === 'test' ? knexSnakeCaseMappers() : {}), +}; + +export default knexConfig; diff --git a/packages/e2e-tests/license-server-with-mock.js b/packages/e2e-tests/license-server-with-mock.js new file mode 100644 index 0000000..bf16794 --- /dev/null +++ b/packages/e2e-tests/license-server-with-mock.js @@ -0,0 +1,28 @@ +const fs = require('node:fs'); +const https = require('node:https'); +const path = require('node:path'); +const { run, send } = require('micro'); + +const options = { + key: fs.readFileSync(path.join(__dirname, './automatisch.io+4-key.pem')), + cert: fs.readFileSync(path.join(__dirname, './automatisch.io+4.pem')), +}; + +const microHttps = (fn) => + https.createServer(options, (req, res) => run(req, res, fn)); + +const server = microHttps(async (req, res) => { + const data = { + id: '7f22d7dd-1fda-4482-83fa-f35bf974a21f', + name: 'Mocked license', + expireAt: '2030-08-09T10:56:54.144Z', + }; + + send(res, 200, data); +}); + +server + .once('listening', () => { + console.log('The mock server is up.'); + }) + .listen(443); diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json new file mode 100644 index 0000000..3f495a5 --- /dev/null +++ b/packages/e2e-tests/package.json @@ -0,0 +1,44 @@ +{ + "name": "@automatisch/e2e-tests", + "version": "0.10.0", + "license": "See LICENSE file", + "private": true, + "description": "The open source Zapier alternative. Build workflow automation without spending time and money.", + "scripts": { + "start-mock-license-server": "node ./license-server-with-mock.js", + "test": "playwright test", + "test:fast": "yarn test -j 90% --quiet --reporter null --ignore-snapshots -x", + "lint": "eslint ." + }, + "contributors": [ + { + "name": "automatisch contributors", + "url": "https://github.com/automatisch/automatisch/graphs/contributors" + } + ], + "homepage": "https://github.com/automatisch/automatisch#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/automatisch/automatisch.git" + }, + "bugs": { + "url": "https://github.com/automatisch/automatisch/issues" + }, + "devDependencies": { + "@faker-js/faker": "^8.2.0", + "@playwright/test": "1.49.0", + "objection": "^3.1.5" + }, + "dependencies": { + "axios": "^1.6.0", + "dotenv": "^16.3.1", + "eslint": "^8.13.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "knex": "^2.4.0", + "luxon": "^3.4.4", + "micro": "^10.0.1", + "pg": "^8.12.0", + "prettier": "^2.5.1" + } +} diff --git a/packages/e2e-tests/playwright.config.js b/packages/e2e-tests/playwright.config.js new file mode 100644 index 0000000..3b44a97 --- /dev/null +++ b/packages/e2e-tests/playwright.config.js @@ -0,0 +1,102 @@ +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + /* Opt out of parallel tests on CI. */ + workers: undefined, + /* Timeout threshold for each test */ + timeout: 30000, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [['html', { open: 'never' }], ['github']] + : [['html', { open: 'never' }]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.BASE_URL || 'http://localhost:3001', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + testIdAttribute: 'data-test', + viewport: { width: 1280, height: 720 }, + }, + + expect: { + /* Timeout threshold for each assertion */ + timeout: 10000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'db-restore', + testMatch: /.*\.teardown\.js/, + }, + { + name: 'setup', + testMatch: /.*\.setup\.js/, + teardown: 'teardown', + dependencies: ['db-restore'], + }, + { + name: 'teardown', + testMatch: /.*\.teardown\.js/, + }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + dependencies: ['setup'], + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/packages/e2e-tests/tests/admin-setup/admin.setup.js b/packages/e2e-tests/tests/admin-setup/admin.setup.js new file mode 100644 index 0000000..43bcc7b --- /dev/null +++ b/packages/e2e-tests/tests/admin-setup/admin.setup.js @@ -0,0 +1,29 @@ +const { publicTest: setup, expect } = require('../../fixtures/index'); + +setup.describe.serial('Admin setup page', () => { + // eslint-disable-next-line no-unused-vars + setup('should not be able to login if admin is not created', async ({ page, adminSetupPage, loginPage }) => { + await expect(async () => { + await expect(await page.url()).toContain(adminSetupPage.path); + }).toPass(); + }); + + setup('should validate the inputs', async ({ adminSetupPage }) => { + await adminSetupPage.open(); + await adminSetupPage.fillInvalidUserData(); + await adminSetupPage.submitAdminForm(); + await adminSetupPage.expectInvalidFields(4); + + await adminSetupPage.fillNotMatchingPasswordUserData(); + await adminSetupPage.submitAdminForm(); + await adminSetupPage.expectInvalidFields(1); + }); + + setup('should create admin', async ({ adminSetupPage }) => { + await adminSetupPage.open(); + await adminSetupPage.fillValidUserData(); + await adminSetupPage.submitAdminForm(); + await adminSetupPage.expectSuccessAlertToBeVisible(); + await adminSetupPage.expectSuccessMessageToContainLoginLink(); + }); +}); diff --git a/packages/e2e-tests/tests/admin/applications.spec.js b/packages/e2e-tests/tests/admin/applications.spec.js new file mode 100644 index 0000000..d66d591 --- /dev/null +++ b/packages/e2e-tests/tests/admin/applications.spec.js @@ -0,0 +1,405 @@ +const { test, expect } = require('../../fixtures/index'); +const { pgPool } = require('../../fixtures/postgres-config'); +const { insertAppConnection } = require('../../helpers/db-helpers'); + +test.describe('Admin Applications', () => { + test.beforeAll(async () => { + const deleteOAuthClients = { + text: 'DELETE FROM oauth_clients WHERE app_key in ($1, $2, $3, $4, $5, $6)', + values: [ + 'carbone', + 'spotify', + 'clickup', + 'mailchimp', + 'reddit', + 'google-drive', + ], + }; + + const deleteAppConfigs = { + text: 'DELETE FROM app_configs WHERE key in ($1, $2, $3, $4, $5, $6)', + values: [ + 'carbone', + 'spotify', + 'clickup', + 'mailchimp', + 'reddit', + 'google-drive', + ], + }; + + try { + const deleteOAuthClientsResult = await pgPool.query(deleteOAuthClients); + expect(deleteOAuthClientsResult.command).toBe('DELETE'); + const deleteAppConfigsResult = await pgPool.query(deleteAppConfigs); + expect(deleteAppConfigsResult.command).toBe('DELETE'); + } catch (err) { + console.error(err.message); + throw err; + } + }); + + test.beforeEach(async ({ adminApplicationsPage }) => { + await adminApplicationsPage.navigateTo(); + }); + + // TODO skip until https://github.com/automatisch/automatisch/pull/2244 + test.skip('Admin should be able to toggle Application settings', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + page, + }) => { + await adminApplicationsPage.openApplication('Carbone'); + await expect(page.url()).toContain('/admin-settings/apps/carbone/settings'); + + await adminApplicationSettingsPage.allowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + await adminApplicationSettingsPage.disallowConnections(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + + await page.reload(); + + await adminApplicationSettingsPage.disallowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + await adminApplicationSettingsPage.allowConnections(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + }); + + test('should allow only custom connections', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, + flowEditorPage, + page, + }) => { + await insertAppConnection('google-drive'); + + // TODO use openApplication method after fix + // await adminApplicationsPage.openApplication('Google-Drive'); + await adminApplicationsPage.searchInput.fill('Google-Drive'); + await adminApplicationsPage.appRow + .locator(page.getByText('Google Drive')) + .click(); + + await expect(page.url()).toContain( + '/admin-settings/apps/google-drive/settings' + ); + + await expect( + adminApplicationSettingsPage.useOnlyPredefinedAuthClients + ).not.toBeChecked(); + await expect( + adminApplicationSettingsPage.disableConnectionsSwitch + ).not.toBeChecked(); + + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await expect( + adminApplicationOAuthClientsPage.createFirstAuthClientButton + ).toHaveCount(1); + + await page.goto('/'); + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + + await expect(flowEditorPage.flowStep).toHaveCount(2); + + await flowEditorPage.chooseAppAndEvent( + 'Google Drive', + 'New files in folder' + ); + await flowEditorPage.connectionAutocomplete.click(); + + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toBeEnabled(); + await expect(newConnectionOption).toHaveCount(1); + await expect(newOAuthConnectionOption).toHaveCount(0); + }); + + test('should allow only predefined connections and existing custom', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, + flowEditorPage, + page, + }) => { + await insertAppConnection('spotify'); + + await adminApplicationsPage.openApplication('Spotify'); + await expect(page.url()).toContain('/admin-settings/apps/spotify/settings'); + + await expect( + adminApplicationSettingsPage.useOnlyPredefinedAuthClients + ).not.toBeChecked(); + await expect( + adminApplicationSettingsPage.disableConnectionsSwitch + ).not.toBeChecked(); + + await adminApplicationSettingsPage.allowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('spotifyAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('spotifyClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('spotifyClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'spotifyAuthClient' + ); + + await page.goto('/'); + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + + await expect(flowEditorPage.flowStep).toHaveCount(2); + const triggerStep = flowEditorPage.flowStep.last(); + await triggerStep.click(); + + await flowEditorPage.chooseAppAndEvent('Spotify', 'Create Playlist'); + await flowEditorPage.connectionAutocomplete.click(); + + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toHaveCount(0); + await expect(newOAuthConnectionOption).toBeEnabled(); + await expect(newOAuthConnectionOption).toHaveCount(1); + }); + + test('should allow all connections', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, + flowEditorPage, + page, + }) => { + await insertAppConnection('reddit'); + + await adminApplicationsPage.openApplication('Reddit'); + await expect(page.url()).toContain('/admin-settings/apps/reddit/settings'); + + await expect( + adminApplicationSettingsPage.useOnlyPredefinedAuthClients + ).not.toBeChecked(); + await expect( + adminApplicationSettingsPage.disableConnectionsSwitch + ).not.toBeChecked(); + + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('redditAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('redditClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('redditClientSecret'); + + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'redditAuthClient' + ); + + await page.goto('/'); + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + + await expect(flowEditorPage.flowStep).toHaveCount(2); + const triggerStep = flowEditorPage.flowStep.last(); + await triggerStep.click(); + + await flowEditorPage.chooseAppAndEvent('Reddit', 'Create link post'); + await flowEditorPage.connectionAutocomplete.click(); + + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toHaveCount(1); + await expect(newOAuthConnectionOption).toBeEnabled(); + await expect(newOAuthConnectionOption).toHaveCount(1); + }); + + test('should not allow new connections but existing custom', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, + flowEditorPage, + page, + }) => { + await insertAppConnection('clickup'); + + await adminApplicationsPage.openApplication('ClickUp'); + await expect(page.url()).toContain('/admin-settings/apps/clickup/settings'); + + await adminApplicationSettingsPage.disallowConnections(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('clickupAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('clickupClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('clickupClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'clickupAuthClient' + ); + + await page.goto('/'); + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + + await expect(flowEditorPage.flowStep).toHaveCount(2); + const triggerStep = flowEditorPage.flowStep.last(); + await triggerStep.click(); + + await flowEditorPage.chooseAppAndEvent('ClickUp', 'Create folder'); + await flowEditorPage.connectionAutocomplete.click(); + + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toHaveCount(0); + await expect(newOAuthConnectionOption).toHaveCount(0); + }); + + test('should not allow new connections but existing custom even if predefined OAuth clients are enabled', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, + flowEditorPage, + page, + }) => { + await insertAppConnection('mailchimp'); + + await adminApplicationsPage.openApplication('Mailchimp'); + await expect(page.url()).toContain( + '/admin-settings/apps/mailchimp/settings' + ); + + await adminApplicationSettingsPage.allowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.disallowConnections(); + await adminApplicationSettingsPage.saveSettings(); + await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('mailchimpAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('mailchimpClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('mailchimpClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'mailchimpAuthClient' + ); + + await page.goto('/'); + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + + await expect(flowEditorPage.flowStep).toHaveCount(2); + const triggerStep = flowEditorPage.flowStep.last(); + await triggerStep.click(); + + await flowEditorPage.chooseAppAndEvent('Mailchimp', 'Create campaign'); + await flowEditorPage.connectionAutocomplete.click(); + await expect(page.getByRole('option').first()).toHaveText('Unnamed'); + + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const noConnectionsOption = page + .locator('.MuiAutocomplete-noOptions') + .filter({ hasText: 'No options' }); + + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(noConnectionsOption).toHaveCount(0); + await expect(newConnectionOption).toHaveCount(0); + await expect(newOAuthConnectionOption).toHaveCount(0); + }); +}); diff --git a/packages/e2e-tests/tests/admin/manage-roles.spec.js b/packages/e2e-tests/tests/admin/manage-roles.spec.js new file mode 100644 index 0000000..2419ee8 --- /dev/null +++ b/packages/e2e-tests/tests/admin/manage-roles.spec.js @@ -0,0 +1,409 @@ +const { test, expect } = require('../../fixtures/index'); +const { LoginPage } = require('../../fixtures/login-page'); +const { AcceptInvitation } = require('../../fixtures/accept-invitation-page'); + +test.describe('Role management page', () => { + test('Admin role is not deletable', async ({ adminRolesPage }) => { + await adminRolesPage.navigateTo(); + const adminRow = await adminRolesPage.getRoleRowByName('Admin'); + await expect(adminRow).toHaveCount(1); + const data = await adminRolesPage.getRowData(adminRow); + await expect(data.role).toBe('Admin'); + await expect(data.canEdit).toBe(true); + await expect(data.canDelete).toBe(false); + }); + + test('Can create, edit, and delete a role', async ({ + adminCreateRolePage, + adminEditRolePage, + adminRolesPage, + }) => { + await test.step('Create a new role', async () => { + await adminRolesPage.navigateTo(); + await adminRolesPage.createRoleButton.click(); + await adminCreateRolePage.isMounted(); + await adminCreateRolePage.waitForPermissionsCatalogToVisible(); + await adminCreateRolePage.nameInput.fill('Create Edit Test'); + await adminCreateRolePage.descriptionInput.fill('Test description'); + await adminCreateRolePage.createButton.click(); + const snackbar = await adminCreateRolePage.getSnackbarData( + 'snackbar-create-role-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + + let roleRow = + await test.step('Make sure role data is correct', async () => { + const roleRow = await adminRolesPage.getRoleRowByName( + 'Create Edit Test' + ); + await expect(roleRow).toHaveCount(1); + const roleData = await adminRolesPage.getRowData(roleRow); + await expect(roleData.role).toBe('Create Edit Test'); + await expect(roleData.description).toBe('Test description'); + await expect(roleData.canEdit).toBe(true); + await expect(roleData.canDelete).toBe(true); + return roleRow; + }); + + await test.step('Edit the role', async () => { + await adminRolesPage.clickEditRole(roleRow); + await adminEditRolePage.isMounted(); + await adminEditRolePage.nameInput.fill('Create Update Test'); + await adminEditRolePage.descriptionInput.fill('Update test description'); + await adminEditRolePage.updateButton.click(); + const snackbar = await adminEditRolePage.getSnackbarData( + 'snackbar-edit-role-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + + roleRow = + await test.step('Make sure changes reflected on roles page', async () => { + await adminRolesPage.isMounted(); + const roleRow = await adminRolesPage.getRoleRowByName( + 'Create Update Test' + ); + await expect(roleRow).toHaveCount(1); + const roleData = await adminRolesPage.getRowData(roleRow); + await expect(roleData.role).toBe('Create Update Test'); + await expect(roleData.description).toBe('Update test description'); + await expect(roleData.canEdit).toBe(true); + await expect(roleData.canDelete).toBe(true); + return roleRow; + }); + + await test.step('Delete the role', async () => { + await adminRolesPage.clickDeleteRole(roleRow); + const deleteModal = adminRolesPage.deleteRoleModal; + await deleteModal.modal.waitFor({ + state: 'attached', + }); + await deleteModal.deleteButton.click(); + const snackbar = await adminRolesPage.getSnackbarData( + 'snackbar-delete-role-success' + ); + await expect(snackbar.variant).toBe('success'); + await deleteModal.modal.waitFor({ + state: 'detached', + }); + await expect(roleRow).toHaveCount(0); + }); + }); + + // This test breaks right now + test.skip('Make sure create/edit role page is scrollable', async ({ + adminRolesPage, + adminEditRolePage, + adminCreateRolePage, + page, + }) => { + const initViewportSize = page.viewportSize; + await page.setViewportSize({ + width: 800, + height: 400, + }); + + await test.step('Ensure create role page is scrollable', async () => { + await adminRolesPage.navigateTo(true); + await adminRolesPage.createRoleButton.click(); + await adminCreateRolePage.isMounted(); + + const initScrollTop = await page.evaluate(() => { + // eslint-disable-next-line no-undef + return document.documentElement.scrollTop; + }); + await page.mouse.move(400, 100); + await page.mouse.click(400, 100); + await page.mouse.wheel(200, 0); + const updatedScrollTop = await page.evaluate(() => { + // eslint-disable-next-line no-undef + return document.documentElement.scrollTop; + }); + await expect(initScrollTop).not.toBe(updatedScrollTop); + }); + + await test.step('Ensure edit role page is scrollable', async () => { + await adminRolesPage.navigateTo(true); + const adminRow = await adminRolesPage.getRoleRowByName('Admin'); + await adminRolesPage.clickEditRole(adminRow); + await adminEditRolePage.isMounted(); + + const initScrollTop = await page.evaluate(() => { + // eslint-disable-next-line no-undef + return document.documentElement.scrollTop; + }); + await page.mouse.move(400, 100); + await page.mouse.wheel(200, 0); + const updatedScrollTop = await page.evaluate(() => { + // eslint-disable-next-line no-undef + return document.documentElement.scrollTop; + }); + await expect(initScrollTop).not.toBe(updatedScrollTop); + }); + + await test.step('Reset viewport', async () => { + await page.setViewportSize(initViewportSize); + }); + }); + + test('Cannot delete a role with a user attached to it', async ({ + adminCreateRolePage, + adminRolesPage, + adminUsersPage, + adminCreateUserPage, + adminEditUserPage, + }) => { + await adminRolesPage.navigateTo(); + await test.step('Create a new role', async () => { + await adminRolesPage.createRoleButton.click(); + await adminCreateRolePage.isMounted(); + await adminCreateRolePage.waitForPermissionsCatalogToVisible(); + await adminCreateRolePage.nameInput.fill('Delete Role'); + await adminCreateRolePage.createButton.click(); + const snackbar = await adminCreateRolePage.getSnackbarData( + 'snackbar-create-role-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + + await test.step('Create a new user with the "Delete Role" role', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill('User Role Test'); + await adminCreateUserPage.emailInput.fill( + 'user-role-test@automatisch.io' + ); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Delete Role', exact: true }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Try to delete "Delete Role" role when new user has it', async () => { + await adminRolesPage.navigateTo(); + const row = await adminRolesPage.getRoleRowByName('Delete Role'); + const modal = await adminRolesPage.clickDeleteRole(row); + await modal.deleteButton.click(); + await expect(modal.deleteAlert).toHaveCount(1); + await modal.close(); + }); + + await test.step('Change the role the user has', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.usersLoader.waitFor({ + state: 'detached', + }); + const row = await adminUsersPage.findUserPageWithEmail( + 'user-role-test@automatisch.io' + ); + await adminUsersPage.clickEditUser(row); + await adminEditUserPage.roleInput.click(); + await adminEditUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminEditUserPage.updateButton.click(); + const snackbar = await adminEditUserPage.getSnackbarData( + 'snackbar-edit-user-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + await test.step('Delete the original role', async () => { + await adminRolesPage.navigateTo(); + const row = await adminRolesPage.getRoleRowByName('Delete Role'); + const modal = await adminRolesPage.clickDeleteRole(row); + await expect(modal.modal).toBeVisible(); + await modal.deleteButton.click(); + const snackbar = await adminRolesPage.getSnackbarData( + 'snackbar-delete-role-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + }); + + test('Deleting a role after deleting a user with that role', async ({ + adminCreateRolePage, + adminRolesPage, + adminUsersPage, + adminCreateUserPage, + }) => { + await adminRolesPage.navigateTo(); + await test.step('Create a new role', async () => { + await adminRolesPage.createRoleButton.click(); + await adminCreateRolePage.isMounted(); + await adminCreateRolePage.waitForPermissionsCatalogToVisible(); + await adminCreateRolePage.nameInput.fill('Cannot Delete Role'); + await adminCreateRolePage.createButton.click(); + const snackbar = await adminCreateRolePage.getSnackbarData( + 'snackbar-create-role-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + await test.step('Create a new user with this role', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.isMounted(); + await adminCreateUserPage.fullNameInput.fill('User Delete Role Test'); + await adminCreateUserPage.emailInput.fill( + 'user-delete-role-test@automatisch.io' + ); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Cannot Delete Role' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Delete this user', async () => { + await adminUsersPage.navigateTo(); + const row = await adminUsersPage.findUserPageWithEmail( + 'user-delete-role-test@automatisch.io' + ); + const modal = await adminUsersPage.clickDeleteUser(row); + await modal.deleteButton.click(); + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-delete-user-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + await test.step('Try deleting this role', async () => { + await adminRolesPage.navigateTo(); + const row = await adminRolesPage.getRoleRowByName('Cannot Delete Role'); + const modal = await adminRolesPage.clickDeleteRole(row); + await modal.deleteButton.click(); + await expect(modal.deleteAlert).toHaveCount(1); + }); + }); +}); + +test('Accessibility of role management page', async ({ + page, + adminUsersPage, + adminCreateUserPage, + adminEditUserPage, + adminRolesPage, + adminCreateRolePage, +}) => { + test.slow(); + await test.step('Create the basic test role', async () => { + await adminRolesPage.navigateTo(); + await adminRolesPage.createRoleButton.click(); + await adminCreateRolePage.isMounted(); + await adminCreateRolePage.waitForPermissionsCatalogToVisible(); + await adminCreateRolePage.nameInput.fill('Basic Test'); + await adminCreateRolePage.createButton.click(); + const snackbar = await adminCreateRolePage.getSnackbarData( + 'snackbar-create-role-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + + await test.step('Create a new user with the basic role', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.isMounted(); + await adminCreateUserPage.fullNameInput.fill('Role Test'); + await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io'); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Basic Test' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Logout and login to the basic role user', async () => { + const acceptInvitationLink = await adminCreateUserPage.acceptInvitationLink; + const acceptInvitationUrl = await acceptInvitationLink.textContent(); + const acceptInvitatonToken = acceptInvitationUrl.split('?token=')[1]; + + await page.getByTestId('profile-menu-button').click(); + await page.getByTestId('logout-item').click(); + + const acceptInvitationPage = new AcceptInvitation(page); + await acceptInvitationPage.open(acceptInvitatonToken); + await acceptInvitationPage.acceptInvitation('sample'); + + const loginPage = new LoginPage(page); + await loginPage.login('basic-role-test@automatisch.io', 'sample'); + await expect(loginPage.loginButton).not.toBeVisible(); + await expect(page).toHaveURL('/flows'); + }); + + await test.step('Navigate to the admin settings page and make sure it is blank', async () => { + const pageUrl = new URL(page.url()); + const url = `${pageUrl.origin}/admin-settings/users`; + await page.goto(url); + await page.waitForTimeout(750); + const isUnmounted = await page.evaluate(() => { + // eslint-disable-next-line no-undef + const root = document.querySelector('#root'); + + if (root) { + // We have react query devtools only in dev env. + // In production, there is nothing in root. + // That's why `<= 1`. + return root.children.length <= 1; + } + + return false; + }); + await expect(isUnmounted).toBe(true); + }); + + await test.step('Log back into the admin account', async () => { + await page.goto('/'); + await page.getByTestId('profile-menu-button').click(); + await page.getByTestId('logout-item').click(); + const loginPage = new LoginPage(page); + await loginPage.isMounted(); + await loginPage.login(); + }); + + await test.step('Move the user off the role', async () => { + await adminUsersPage.navigateTo(); + const row = await adminUsersPage.findUserPageWithEmail( + 'basic-role-test@automatisch.io' + ); + await adminUsersPage.clickEditUser(row); + await adminEditUserPage.isMounted(); + await adminEditUserPage.roleInput.click(); + await adminEditUserPage.page.getByRole('option', { name: 'Admin' }).click(); + await adminEditUserPage.updateButton.click(); + const snackbar = await adminEditUserPage.getSnackbarData( + 'snackbar-edit-user-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + + await test.step('Delete the role', async () => { + await adminRolesPage.navigateTo(); + const roleRow = await adminRolesPage.getRoleRowByName('Basic Test'); + await adminRolesPage.clickDeleteRole(roleRow); + const deleteModal = adminRolesPage.deleteRoleModal; + await deleteModal.modal.waitFor({ + state: 'attached', + }); + await deleteModal.deleteButton.click(); + const snackbar = await adminRolesPage.getSnackbarData( + 'snackbar-delete-role-success' + ); + await expect(snackbar.variant).toBe('success'); + await deleteModal.modal.waitFor({ + state: 'detached', + }); + await expect(roleRow).toHaveCount(0); + }); +}); diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js new file mode 100644 index 0000000..e62b6bb --- /dev/null +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -0,0 +1,216 @@ +const { test, expect } = require('../../fixtures/index'); + +/** + * NOTE: Make sure to delete all users generated between test runs, + * otherwise tests will fail since users are only *soft*-deleted + */ +test.describe('User management page', () => { + test.beforeEach(async ({ adminUsersPage }) => { + await adminUsersPage.navigateTo(); + await adminUsersPage.closeAllSnackbars(); + }); + + test('User creation and deletion process', async ({ + adminCreateUserPage, + adminEditUserPage, + adminUsersPage, + }) => { + adminCreateUserPage.seed(9000); + const user = adminCreateUserPage.generateUser(); + await adminUsersPage.usersLoader.waitFor({ + state: 'detached' /* Note: state: 'visible' introduces flakiness + because visibility: hidden is used as part of the state transition in + notistack, see + https://github.com/iamhosseindhv/notistack/blob/122f47057eb7ce5a1abfe923316cf8475303e99a/src/transitions/Collapse/Collapse.tsx#L110 + */, + }); + await test.step('Create a user', async () => { + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(user.fullName); + await adminCreateUserPage.emailInput.fill(user.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + await adminUsersPage.navigateTo(); + }); + await test.step('Check the user exists with the expected properties', async () => { + await adminUsersPage.findUserPageWithEmail(user.email); + const userRow = await adminUsersPage.getUserRowByEmail(user.email); + const data = await adminUsersPage.getRowData(userRow); + await expect(data.email).toBe(user.email); + await expect(data.fullName).toBe(user.fullName); + await expect(data.role).toBe('Admin'); + }); + await test.step('Edit user info and make sure the edit works correctly', async () => { + await adminUsersPage.findUserPageWithEmail(user.email); + + let userRow = await adminUsersPage.getUserRowByEmail(user.email); + await adminUsersPage.clickEditUser(userRow); + await adminEditUserPage.waitForLoad(user.fullName); + const newUserInfo = adminEditUserPage.generateUser(); + await adminEditUserPage.fullNameInput.fill(newUserInfo.fullName); + await adminEditUserPage.updateButton.click(); + + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-edit-user-success' + ); + await expect(snackbar.variant).toBe('success'); + + await adminUsersPage.findUserPageWithEmail(user.email); + userRow = await adminUsersPage.getUserRowByEmail(user.email); + const rowData = await adminUsersPage.getRowData(userRow); + await expect(rowData.fullName).toBe(newUserInfo.fullName); + }); + await test.step('Delete user and check the page confirms this deletion', async () => { + await adminUsersPage.findUserPageWithEmail(user.email); + const userRow = await adminUsersPage.getUserRowByEmail(user.email); + await adminUsersPage.clickDeleteUser(userRow); + const modal = adminUsersPage.deleteUserModal; + await modal.deleteButton.click(); + + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-delete-user-success' + ); + await expect(snackbar.variant).toBe('success'); + }); + }); + + test('Creating a user which has been deleted', async ({ + adminCreateUserPage, + adminUsersPage, + }) => { + adminCreateUserPage.seed(9100); + const testUser = adminCreateUserPage.generateUser(); + await test.step('Create the test user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Delete the created user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.findUserPageWithEmail(testUser.email); + const userRow = await adminUsersPage.getUserRowByEmail(testUser.email); + await adminUsersPage.clickDeleteUser(userRow); + const modal = adminUsersPage.deleteUserModal; + await modal.deleteButton.click(); + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-delete-user-success' + ); + await expect(snackbar).not.toBeNull(); + await expect(snackbar.variant).toBe('success'); + }); + + await test.step('Create the user again', async () => { + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await expect(adminCreateUserPage.fieldError).toHaveCount(1); + }); + }); + + test('Creating a user which already exists', async ({ + adminCreateUserPage, + adminUsersPage, + page, + }) => { + adminCreateUserPage.seed(9200); + const testUser = adminCreateUserPage.generateUser(); + + await test.step('Create the test user', async () => { + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Create the user again', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + const createUserPageUrl = page.url(); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + + await expect(page.url()).toBe(createUserPageUrl); + await expect(adminCreateUserPage.fieldError).toHaveCount(1); + }); + }); + + test('Editing a user to have the same email as another user should not be allowed', async ({ + adminCreateUserPage, + adminEditUserPage, + adminUsersPage, + page, + }) => { + adminCreateUserPage.seed(9300); + const user1 = adminCreateUserPage.generateUser(); + const user2 = adminCreateUserPage.generateUser(); + await test.step('Create the first user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(user1.fullName); + await adminCreateUserPage.emailInput.fill(user1.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Create the second user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(user2.fullName); + await adminCreateUserPage.emailInput.fill(user2.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Try editing the second user to have the email of the first user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.findUserPageWithEmail(user2.email); + let userRow = await adminUsersPage.getUserRowByEmail(user2.email); + await adminUsersPage.clickEditUser(userRow); + await adminEditUserPage.waitForLoad(user2.fullName); + await adminEditUserPage.emailInput.fill(user1.email); + const editPageUrl = page.url(); + await adminEditUserPage.updateButton.click(); + + await expect(adminEditUserPage.fieldError).toHaveCount(1); + await expect(page.url()).toBe(editPageUrl); + }); + }); +}); diff --git a/packages/e2e-tests/tests/admin/role-conditions.spec.js b/packages/e2e-tests/tests/admin/role-conditions.spec.js new file mode 100644 index 0000000..1b73840 --- /dev/null +++ b/packages/e2e-tests/tests/admin/role-conditions.spec.js @@ -0,0 +1,69 @@ +const { test, expect } = require('../../fixtures/index'); + +test( + 'Role permissions conform with role conditions ', + async({ adminRolesPage, adminCreateRolePage }) => { + await adminRolesPage.navigateTo(); + await adminRolesPage.createRoleButton.click(); + + /* + example config: { + action: 'read', + subject: 'connection', + row: page.getByTestId('connection-permission-row'), + locator: row.getByTestId('read-checkbox') + } + */ + const permissionConfigs = + await adminCreateRolePage.getPermissionConfigs(); + + await test.step( + 'Iterate over each permission config and make sure role conditions conform', + async () => { + for (let config of permissionConfigs) { + await config.locator.click(); + await adminCreateRolePage.clickPermissionSettings(config.row); + const modal = adminCreateRolePage.getRoleConditionsModal( + config.subject + ); + await expect(modal.modal).toBeVisible(); + const conditions = await modal.getAvailableConditions(); + for (let conditionAction of Object.keys(conditions)) { + if (conditionAction === config.action) { + await expect(conditions[conditionAction]).not.toBeDisabled(); + } else { + await expect(conditions[conditionAction]).toBeDisabled(); + } + } + await modal.close(); + await config.locator.click(); + } + } + ); + } +); + +test( + 'Default role permissions conforms with role conditions', + async({ adminRolesPage, adminCreateRolePage }) => { + await adminRolesPage.navigateTo(); + await adminRolesPage.createRoleButton.click(); + + const subjects = ['Connection', 'Execution', 'Flow']; + for (let subject of subjects) { + const row = adminCreateRolePage.getSubjectRow(subject); + const modal = adminCreateRolePage.getRoleConditionsModal(subject); + await adminCreateRolePage.clickPermissionSettings(row); + await expect(modal.modal).toBeVisible(); + const availableConditions = await modal.getAvailableConditions(); + const conditions = ['create', 'read', 'update', 'delete', 'publish']; + for (let condition of conditions) { + if (availableConditions[condition]) { + await expect(availableConditions[condition]).toBeDisabled(); + } + } + await modal.close(); + } + + } +); \ No newline at end of file diff --git a/packages/e2e-tests/tests/app-integrations/github.spec.js b/packages/e2e-tests/tests/app-integrations/github.spec.js new file mode 100644 index 0000000..f234886 --- /dev/null +++ b/packages/e2e-tests/tests/app-integrations/github.spec.js @@ -0,0 +1,63 @@ +const { test, expect } = require('../../fixtures'); + +test('Github OAuth integration', async ({ page, applicationsPage }) => { + const githubConnectionPage = await test.step( + 'Navigate to github connections modal', + async () => { + await applicationsPage.drawerLink.click(); + if (page.url() !== '/apps') { + await page.waitForURL('/apps'); + } + const connectionModal = await applicationsPage.openAddConnectionModal(); + await expect(connectionModal.modal).toBeVisible(); + return await connectionModal.selectLink('github'); + } + ); + + const connectionModal = await test.step( + 'Ensure the github connection modal is visible', + async () => { + const connectionModal = githubConnectionPage.addConnectionModal; + await expect(connectionModal.modal).toBeVisible(); + return connectionModal; + } + ); + + const githubPopup = await test.step( + 'Input data into the add connection form and submit', + async () => { + await connectionModal.clientIdInput.fill(process.env.GITHUB_CLIENT_ID); + await connectionModal.clientIdSecretInput.fill( + process.env.GITHUB_CLIENT_SECRET + ); + return await connectionModal.submit(); + } + ); + + await test.step('Ensure github popup is not a 404', async () => { + // await expect(githubPopup).toBeVisible(); + const title = await githubPopup.title(); + await expect(title).not.toMatch(/^Page not found/); + }); + + /* Skip these in CI + await test.step( + 'Handle github popup authentication flow', + async () => { + await connectionModal.handlePopup(githubPopup); + } + ); + + await test.step( + 'Ensure the new connection is added to the connections list', + async () => { + await page.locator('body').click({ position: { x: 0, y: 0 } }); + // TODO + } + ); + */ +}); + +test.afterAll(async () => { + // TODO - Remove connections from github connections page +}); \ No newline at end of file diff --git a/packages/e2e-tests/tests/app-integrations/webhook.spec.js b/packages/e2e-tests/tests/app-integrations/webhook.spec.js new file mode 100644 index 0000000..4eea2aa --- /dev/null +++ b/packages/e2e-tests/tests/app-integrations/webhook.spec.js @@ -0,0 +1,95 @@ +const { test, expect } = require('../../fixtures/index'); + +test.describe('Webhook flow', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + await expect(page.getByTestId('flow-step')).toHaveCount(2); + }); + + test('Create a new flow with a sync Webhook step then a Webhook step', async ({ + flowEditorPage, + page, + request, + }) => { + await flowEditorPage.flowName.click(); + await flowEditorPage.flowNameInput.fill('syncWebhook'); + const syncWebhookUrl = await flowEditorPage.createWebhookTrigger(true); + + await flowEditorPage.chooseAppAndEvent('Webhook', 'Respond with'); + + await expect(flowEditorPage.continueButton).toHaveCount(1); + await expect(flowEditorPage.continueButton).not.toBeEnabled(); + + await expect( + page + .getByTestId('parameters.statusCode-power-input') + .locator('[contenteditable]') + ).toHaveText('200'); + await flowEditorPage.clickAway(); + await expect(flowEditorPage.continueButton).toHaveCount(1); + await expect(flowEditorPage.continueButton).not.toBeEnabled(); + + await page + .getByTestId('parameters.body-power-input') + .locator('[contenteditable]') + .fill('response from webhook'); + await flowEditorPage.clickAway(); + await expect( + page.getByTestId('parameters.headers.0.key-power-input') + ).toBeVisible(); + await expect(flowEditorPage.continueButton).toBeEnabled(); + await flowEditorPage.continueButton.click(); + + await flowEditorPage.testAndContinue(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.infoSnackbar).toBeVisible(); + + const response = await request.get(syncWebhookUrl); + await expect(response.status()).toBe(200); + await expect(await response.text()).toBe('response from webhook'); + }); + + test('Create a new flow with an async Webhook step then a Webhook step', async ({ + flowEditorPage, + page, + request, + }) => { + await flowEditorPage.flowName.click(); + await flowEditorPage.flowNameInput.fill('asyncWebhook'); + const asyncWebhookUrl = await flowEditorPage.createWebhookTrigger(false); + + await flowEditorPage.chooseAppAndEvent('Webhook', 'Respond with'); + await expect(flowEditorPage.continueButton).toHaveCount(1); + await expect(flowEditorPage.continueButton).not.toBeEnabled(); + + await page + .getByTestId('parameters.statusCode-power-input') + .locator('[contenteditable]') + .fill('200'); + await flowEditorPage.clickAway(); + await expect(flowEditorPage.continueButton).toHaveCount(1); + await expect(flowEditorPage.continueButton).not.toBeEnabled(); + + await page + .getByTestId('parameters.body-power-input') + .locator('[contenteditable]') + .fill('response from webhook'); + await flowEditorPage.clickAway(); + await expect( + page.getByTestId('parameters.headers.0.key-power-input') + ).toBeVisible(); + await expect(flowEditorPage.continueButton).toBeEnabled(); + await flowEditorPage.continueButton.click(); + + await flowEditorPage.testAndContinue(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.infoSnackbar).toBeVisible(); + + const response = await request.get(asyncWebhookUrl); + await expect(response.status()).toBe(204); + await expect(await response.text()).toBe(''); + }); +}); diff --git a/packages/e2e-tests/tests/apps/list-apps.spec.js b/packages/e2e-tests/tests/apps/list-apps.spec.js new file mode 100644 index 0000000..b382782 --- /dev/null +++ b/packages/e2e-tests/tests/apps/list-apps.spec.js @@ -0,0 +1,91 @@ +const { test, expect } = require('../../fixtures/index'); + +test.describe('Apps page', () => { + test.beforeEach(async ({ applicationsPage }) => { + await applicationsPage.drawerLink.click(); + }); + + // no connected application exists in an empty account + test.skip('displays no applications', async ({ applicationsPage }) => { + await applicationsPage.page.getByTestId('apps-loader').waitFor({ + state: 'detached', + }); + await expect(applicationsPage.page.getByTestId('app-row')).not.toHaveCount( + 0 + ); + + await applicationsPage.screenshot({ + path: 'Applications.png', + }); + }); + + test.describe('can add connection', () => { + test.beforeEach(async ({ applicationsPage }) => { + await expect(applicationsPage.addConnectionButton).toBeClickableLink(); + await applicationsPage.addConnectionButton.click(); + await applicationsPage.page + .getByTestId('search-for-app-loader') + .waitFor({ state: 'detached' }); + }); + + test('lists applications', async ({ applicationsPage }) => { + const appListItemCount = await applicationsPage.page + .getByTestId('app-list-item') + .count(); + expect(appListItemCount).toBeGreaterThan(10); + + await applicationsPage.clickAway(); + }); + + test('searches an application', async ({ applicationsPage }) => { + await applicationsPage.page + .getByTestId('search-for-app-text-field') + .fill('DeepL'); + await applicationsPage.page + .getByTestId('search-for-app-loader') + .waitFor({ state: 'detached' }); + + await expect( + applicationsPage.page.getByTestId('app-list-item') + ).toHaveCount(1); + + await applicationsPage.clickAway(); + }); + + test('goes to app page to create a connection', async ({ + applicationsPage, + }) => { + // loading app, app config, app oauth clients take time + test.setTimeout(60000); + + await applicationsPage.page.getByTestId('app-list-item').first().click(); + await expect(applicationsPage.page).toHaveURL( + '/app/airtable/connections/add?shared=false' + ); + await expect( + applicationsPage.page.getByTestId('add-app-connection-dialog') + ).toBeVisible(); + + await applicationsPage.clickAway(); + }); + + test('closes the dialog on backdrop click', async ({ + applicationsPage, + }) => { + await applicationsPage.page.getByTestId('app-list-item').first().click(); + await expect(applicationsPage.page).toHaveURL( + '/app/airtable/connections/add?shared=false' + ); + await expect( + applicationsPage.page.getByTestId('add-app-connection-dialog') + ).toBeVisible(); + await applicationsPage.clickAway(); + await expect(applicationsPage.page).toHaveURL( + '/app/airtable/connections' + ); + await expect( + applicationsPage.page.getByTestId('add-app-connection-dialog') + ).toBeHidden(); + }); + }); +}); diff --git a/packages/e2e-tests/tests/authentication/login.spec.js b/packages/e2e-tests/tests/authentication/login.spec.js new file mode 100644 index 0000000..55f3a2d --- /dev/null +++ b/packages/e2e-tests/tests/authentication/login.spec.js @@ -0,0 +1,21 @@ +const { publicTest, expect } = require('../../fixtures/index'); + +publicTest.describe('Login page', () => { + publicTest('shows login form', async ({ loginPage }) => { + await loginPage.emailTextField.waitFor({ state: 'attached' }); + await loginPage.passwordTextField.waitFor({ state: 'attached' }); + await loginPage.loginButton.waitFor({ state: 'attached' }); + }); + + publicTest('lets user login', async ({ loginPage }) => { + await loginPage.login(); + + await expect(loginPage.page).toHaveURL('/flows'); + }); + + publicTest(`doesn't let un-existing user login`, async ({ loginPage }) => { + await loginPage.login('nonexisting@automatisch.io', 'sample'); + + await expect(loginPage.page).toHaveURL('/login'); + }); +}); diff --git a/packages/e2e-tests/tests/connections/create-connection.spec.js b/packages/e2e-tests/tests/connections/create-connection.spec.js new file mode 100644 index 0000000..511f3bb --- /dev/null +++ b/packages/e2e-tests/tests/connections/create-connection.spec.js @@ -0,0 +1,49 @@ +const { test, expect } = require('../../fixtures/index'); + +test.describe('Connections page', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('apps-page-drawer-link').click(); + await page.goto('/app/ntfy/connections'); + }); + + test('shows connections if any', async ({ page, connectionsPage }) => { + await page.getByTestId('apps-loader').waitFor({ + state: 'detached', + }); + + await connectionsPage.screenshot({ + path: 'Connections.png', + }); + }); + + test.describe('can add connection', () => { + test('has a button to open add connection dialog', async ({ page }) => { + await expect(page.getByTestId('add-connection-button')).toBeClickableLink(); + }); + + test('add connection button takes user to add connection page', async ({ + page, + connectionsPage, + }) => { + await connectionsPage.clickAddConnectionButton(); + await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false'); + }); + + test('shows add connection dialog to create a new connection', async ({ + page, + connectionsPage, + }) => { + await connectionsPage.clickAddConnectionButton(); + await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false'); + await expect(page.getByTestId('create-connection-button')).not.toBeDisabled(); + await page.getByTestId('create-connection-button').click(); + await expect( + page.getByTestId('create-connection-button') + ).not.toBeVisible(); + + await connectionsPage.screenshot({ + path: 'Ntfy connections after creating a connection.png', + }); + }); + }); +}); diff --git a/packages/e2e-tests/tests/connections/enabled-pop-up-reminder.spec.js b/packages/e2e-tests/tests/connections/enabled-pop-up-reminder.spec.js new file mode 100644 index 0000000..066f6cf --- /dev/null +++ b/packages/e2e-tests/tests/connections/enabled-pop-up-reminder.spec.js @@ -0,0 +1,103 @@ +const { request } = require('@playwright/test'); +const { test, expect } = require('../../fixtures/index'); +const { + AddMattermostConnectionModal, +} = require('../../fixtures/apps/mattermost/add-mattermost-connection-modal'); +const { + createFlow, + updateFlowName, + getFlow, + updateFlowStep, + testStep, +} = require('../../helpers/flow-api-helper'); +const { getToken } = require('../../helpers/auth-api-helper'); + +test.describe('Pop-up message on connections', () => { + test.beforeEach(async ({ flowEditorPage, page }) => { + const apiRequest = await request.newContext(); + const tokenJsonResponse = await getToken(apiRequest); + const token = tokenJsonResponse.data.token; + + let flow = await createFlow(apiRequest, token); + const flowId = flow.data.id; + await updateFlowName(apiRequest, token, flowId); + flow = await getFlow(apiRequest, token, flowId); + const flowSteps = flow.data.steps; + const triggerStepId = flowSteps.find((step) => step.type === 'trigger').id; + const actionStepId = flowSteps.find((step) => step.type === 'action').id; + + const triggerStep = await updateFlowStep(apiRequest, token, triggerStepId, { + appKey: 'webhook', + key: 'catchRawWebhook', + parameters: { + workSynchronously: false, + }, + }); + await apiRequest.get(triggerStep.data.webhookUrl); + await testStep(apiRequest, token, triggerStepId); + + await updateFlowStep(apiRequest, token, actionStepId, { + appKey: 'mattermost', + key: 'sendMessageToChannel', + }); + await testStep(apiRequest, token, actionStepId); + + await page.reload(); + + const flowRow = await page.getByTestId('flow-row').filter({ + hasText: flowId, + }); + await flowRow.click(); + const flowTriggerStep = await page.getByTestId('flow-step').nth(1); + await flowTriggerStep.click(); + await page.getByText('Choose connection').click(); + + await flowEditorPage.connectionAutocomplete.click(); + await flowEditorPage.addNewConnectionItem.click(); + }); + + test('should show error to remind to enable pop-up on connection create', async ({ + page, + }) => { + const addMattermostConnectionModal = new AddMattermostConnectionModal(page); + + // Inject script to override window.open + await page.evaluate(() => { + // eslint-disable-next-line no-undef + window.open = function () { + console.log('Popup blocked!'); + return null; + }; + }); + + await addMattermostConnectionModal.fillConnectionForm(); + await addMattermostConnectionModal.submitConnectionForm(); + + await expect(page.getByTestId('add-connection-error')).toHaveCount(1); + await expect(page.getByTestId('add-connection-error')).toHaveText( + 'Make sure pop-ups are enabled in your browser.' + ); + }); + + test('should not show pop-up error if pop-ups are enabled on connection create', async ({ + page, + }) => { + const addMattermostConnectionModal = new AddMattermostConnectionModal(page); + + await addMattermostConnectionModal.fillConnectionForm(); + const popupPromise = page.waitForEvent('popup'); + await addMattermostConnectionModal.submitConnectionForm(); + + const popup = await popupPromise; + await expect(popup.url()).toContain('mattermost'); + await expect(page.getByTestId('add-connection-error')).toHaveCount(0); + + await test.step('Should show error on failed credentials verification', async () => { + await popup.close(); + await expect(page.getByTestId('add-connection-error')).toHaveCount(1); + await expect(page.getByTestId('add-connection-error')).toHaveText( + 'Error occured while verifying credentials!' + ); + }); + }); +}); diff --git a/packages/e2e-tests/tests/executions/display-execution.spec.js b/packages/e2e-tests/tests/executions/display-execution.spec.js new file mode 100644 index 0000000..8de79dd --- /dev/null +++ b/packages/e2e-tests/tests/executions/display-execution.spec.js @@ -0,0 +1,37 @@ +const { test, expect } = require('../../fixtures/index'); + +// no execution data exists in an empty account +test.describe.skip('Executions page', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('executions-page-drawer-link').click(); + await page.getByTestId('execution-row').first().click(); + + await expect(page).toHaveURL(/\/executions\//); + }); + + test('displays data in by default', async ({ page, executionsPage }) => { + await expect(page.getByTestId('execution-step').last()).toBeVisible(); + await expect(page.getByTestId('execution-step')).toHaveCount(2); + + await executionsPage.screenshot({ + path: 'Execution - data in.png', + }); + }); + + test('displays data out', async ({ page, executionsPage }) => { + const executionStepCount = await page.getByTestId('execution-step').count(); + for (let i = 0; i < executionStepCount; i++) { + await page.getByTestId('data-out-tab').nth(i).click(); + await expect(page.getByTestId('data-out-panel').nth(i)).toBeVisible(); + + await executionsPage.screenshot({ + path: `Execution - data out - ${i}.png`, + animations: 'disabled', + }); + } + }); + + test('does not display error', async ({ page }) => { + await expect(page.getByTestId('error-tab')).toBeHidden(); + }); +}); diff --git a/packages/e2e-tests/tests/executions/list-executions.spec.js b/packages/e2e-tests/tests/executions/list-executions.spec.js new file mode 100644 index 0000000..4852722 --- /dev/null +++ b/packages/e2e-tests/tests/executions/list-executions.spec.js @@ -0,0 +1,17 @@ +const { test, expect } = require('../../fixtures/index'); + +test.describe('Executions page', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('executions-page-drawer-link').click(); + }); + + // no executions exist in an empty account + test.skip('displays executions', async ({ page, executionsPage }) => { + await page.getByTestId('executions-loader').waitFor({ + state: 'detached', + }); + await expect(page.getByTestId('execution-row').first()).toBeVisible(); + + await executionsPage.screenshot({ path: 'Executions.png' }); + }); +}); diff --git a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js new file mode 100644 index 0000000..6f46454 --- /dev/null +++ b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js @@ -0,0 +1,197 @@ +const { test, expect } = require('../../fixtures/index'); + +test('Ensure creating a new flow works', async ({ page }) => { + await page.getByTestId('create-flow-button').click(); + await expect(page).toHaveURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); +}); + +test('Create a new flow with a Scheduler step then an Ntfy step', async ({ + flowEditorPage, + page, +}) => { + await test.step('create flow', async () => { + await test.step('navigate to new flow page', async () => { + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + }); + + await test.step('has two steps by default', async () => { + await expect(page.getByTestId('flow-step')).toHaveCount(2); + }); + }); + + await test.step('setup Scheduler trigger', async () => { + await test.step('choose app and event substep', async () => { + await test.step('choose application', async () => { + await flowEditorPage.appAutocomplete.click(); + await page.getByRole('option', { name: 'Scheduler' }).click(); + }); + + await test.step('choose and event', async () => { + await expect(flowEditorPage.eventAutocomplete).toBeVisible(); + await flowEditorPage.eventAutocomplete.click(); + await page.getByRole('option', { name: 'Every hour' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); + await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('set up a trigger', async () => { + await test.step('choose "yes" in "trigger on weekends?"', async () => { + await expect(flowEditorPage.trigger).toBeVisible(); + await flowEditorPage.trigger.click(); + await page.getByRole('option', { name: 'Yes' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.trigger).not.toBeVisible(); + }); + }); + + await test.step('test trigger', async () => { + await test.step('show sample output', async () => { + await expect(flowEditorPage.testOutput).not.toBeVisible(); + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.testOutput).toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Scheduler trigger test output.png', + }); + await flowEditorPage.continueButton.click(); + }); + }); + }); + + await test.step('arrange Ntfy action', async () => { + await test.step('choose app and event substep', async () => { + await test.step('choose application', async () => { + await flowEditorPage.appAutocomplete.click(); + await page.getByRole('option', { name: 'Ntfy' }).click(); + }); + + await test.step('choose an event', async () => { + await expect(flowEditorPage.eventAutocomplete).toBeVisible(); + await flowEditorPage.eventAutocomplete.click(); + await page.getByRole('option', { name: 'Send message' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); + await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('choose connection substep', async () => { + await test.step('choose connection list item', async () => { + await flowEditorPage.connectionAutocomplete.click(); + await page + .getByRole('option') + .filter({ hasText: 'Add new connection' }) + .click(); + }); + + await test.step('continue to next step', async () => { + await page.getByTestId('create-connection-button').click(); + }); + + await test.step('collapses the substep', async () => { + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('set up action substep', async () => { + await test.step('fill topic and message body', async () => { + await page + .getByTestId('parameters.topic-power-input') + .locator('[contenteditable]') + .fill('Topic'); + await page + .getByTestId('parameters.message-power-input') + .locator('[contenteditable]') + .fill('Message body'); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('test trigger substep', async () => { + await test.step('show sample output', async () => { + await expect(flowEditorPage.testOutput).not.toBeVisible(); + await page.getByTestId('flow-substep-continue-button').first().click(); + await expect(flowEditorPage.testOutput).toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Ntfy action test output.png', + }); + await flowEditorPage.continueButton.click(); + }); + }); + }); + + await test.step('publish and unpublish', async () => { + await test.step('publish flow', async () => { + await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + await expect(flowEditorPage.publishFlowButton).toBeVisible(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + }); + + await test.step('shows read-only sticky snackbar', async () => { + await expect(flowEditorPage.infoSnackbar).toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Published flow.png', + }); + }); + + await test.step('unpublish from snackbar', async () => { + await page.getByTestId('unpublish-flow-from-snackbar').click(); + await expect(flowEditorPage.infoSnackbar).not.toBeVisible(); + }); + + await test.step('publish once again', async () => { + await expect(flowEditorPage.publishFlowButton).toBeVisible(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + }); + + await test.step('unpublish from layout top bar', async () => { + await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); + await flowEditorPage.unpublishFlowButton.click(); + await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Unpublished flow.png', + }); + }); + }); + + await test.step('in layout', async () => { + await test.step('can go back to flows page', async () => { + await page.getByTestId('editor-go-back-button').click(); + await expect(page).toHaveURL('/flows'); + }); + }); +}); diff --git a/packages/e2e-tests/tests/global.teardown.js b/packages/e2e-tests/tests/global.teardown.js new file mode 100644 index 0000000..20bc97a --- /dev/null +++ b/packages/e2e-tests/tests/global.teardown.js @@ -0,0 +1,12 @@ +const { publicTest } = require('../fixtures'); +import knex from 'knex'; +import knexConfig from '../knexfile.js'; + +publicTest.describe('restore db', () => { + publicTest('clean db and perform migrations', async () => { + const knexClient = knex(knexConfig); + const migrator = knexClient.migrate; + await migrator.rollback({}, true); + await migrator.latest(); + }); +}); diff --git a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js new file mode 100644 index 0000000..0684173 --- /dev/null +++ b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js @@ -0,0 +1,169 @@ +const { request } = require('@playwright/test'); +const { publicTest, expect } = require('../../fixtures/index'); +const { MyProfilePage } = require('../../fixtures/my-profile-page'); +const { LoginPage } = require('../../fixtures/login-page'); +const { addUser, acceptInvitation } = require('../../helpers/user-api-helper'); +const { getToken } = require('../../helpers/auth-api-helper'); + +publicTest.describe('My Profile', () => { + let testUser; + + publicTest.beforeEach(async ({ adminCreateUserPage, loginPage, page }) => { + let addUserResponse; + const apiRequest = await request.newContext(); + + adminCreateUserPage.seed( + Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER) + ); + testUser = adminCreateUserPage.generateUser(); + + await publicTest.step('create new user', async () => { + const tokenJsonResponse = await getToken(apiRequest); + addUserResponse = await addUser( + apiRequest, + tokenJsonResponse.data.token, + { + fullName: testUser.fullName, + email: testUser.email, + } + ); + }); + + await publicTest.step('accept invitation', async () => { + let acceptToken = addUserResponse.data.acceptInvitationUrl.split('=')[1]; + await acceptInvitation(apiRequest, { + token: acceptToken, + password: LoginPage.defaultPassword, + }); + }); + + await publicTest.step('login as new Admin', async () => { + await loginPage.login(testUser.email, LoginPage.defaultPassword); + await expect(loginPage.loginButton).not.toBeVisible(); + await expect(page).toHaveURL('/flows'); + }); + }); + + publicTest('user should be able to change own data', async ({ page }) => { + const myProfilePage = new MyProfilePage(page); + + await publicTest.step('change own data', async () => { + await myProfilePage.navigateTo(); + + await myProfilePage.fullName.fill('abecadło'); + await myProfilePage.email.fill('a' + testUser.email); + await myProfilePage.updateProfileButton.click(); + }); + + await publicTest.step('verify changed data', async () => { + await expect(myProfilePage.fullName).toHaveValue('abecadło'); + await expect(myProfilePage.email).toHaveValue('a' + testUser.email); + + await page.reload(); + + await expect(myProfilePage.fullName).toHaveValue('abecadło'); + await expect(myProfilePage.email).toHaveValue('a' + testUser.email); + }); + }); + + publicTest( + 'user should not be able to change email to already existing one', + async ({ page }) => { + const myProfilePage = new MyProfilePage(page); + + await publicTest.step('change email to existing one', async () => { + await myProfilePage.navigateTo(); + + await myProfilePage.email.fill(LoginPage.defaultEmail); + await myProfilePage.updateProfileButton.click(); + }); + + await publicTest.step('verify error message', async () => { + const snackbar = await myProfilePage.getSnackbarData( + 'snackbar-update-profile-settings-error' + ); + await expect(snackbar.variant).toBe('error'); + }); + } + ); + + publicTest( + 'user should be able to change own password', + async ({ loginPage, page }) => { + const myProfilePage = new MyProfilePage(page); + + await publicTest.step('change own password', async () => { + await myProfilePage.navigateTo(); + + await myProfilePage.currentPassword.fill(LoginPage.defaultPassword); + await myProfilePage.newPassword.fill( + LoginPage.defaultPassword + LoginPage.defaultPassword + ); + await myProfilePage.passwordConfirmation.fill( + LoginPage.defaultPassword + LoginPage.defaultPassword + ); + await myProfilePage.updatePasswordButton.click(); + }); + + await publicTest.step('logout', async () => { + await myProfilePage.logout(); + }); + + await publicTest.step('login with new credentials', async () => { + await loginPage.login( + testUser.email, + LoginPage.defaultPassword + LoginPage.defaultPassword + ); + await expect(loginPage.loginButton).not.toBeVisible(); + await expect(page).toHaveURL('/flows'); + }); + + await publicTest.step('verify if user is the same', async () => { + await myProfilePage.navigateTo(); + await expect(myProfilePage.email).toHaveValue(testUser.email); + }); + } + ); + + publicTest( + 'user should not be able to change own password if current one is incorrect', + async ({ loginPage, page }) => { + const myProfilePage = new MyProfilePage(page); + + await publicTest.step('change own password', async () => { + await myProfilePage.navigateTo(); + + await myProfilePage.currentPassword.fill('wrongpassword'); + await myProfilePage.newPassword.fill( + LoginPage.defaultPassword + LoginPage.defaultPassword + ); + await myProfilePage.passwordConfirmation.fill( + LoginPage.defaultPassword + LoginPage.defaultPassword + ); + await myProfilePage.updatePasswordButton.click(); + }); + + await publicTest.step('verify error message', async () => { + const snackbar = await myProfilePage.getSnackbarData( + 'snackbar-update-password-error' + ); + await expect(snackbar.variant).toBe('error'); + }); + + await publicTest.step('logout', async () => { + await myProfilePage.logout(); + }); + + await publicTest.step('login with old credentials', async () => { + await loginPage.login(testUser.email, LoginPage.defaultPassword); + await expect(loginPage.loginButton).not.toBeVisible(); + await expect(page).toHaveURL('/flows'); + }); + + await publicTest.step('verify if user is the same', async () => { + await myProfilePage.navigateTo(); + await expect(myProfilePage.email).toHaveValue(testUser.email); + }); + } + ); +}); diff --git a/packages/e2e-tests/tests/user-interface/user-interface-configuration.spec.js b/packages/e2e-tests/tests/user-interface/user-interface-configuration.spec.js new file mode 100644 index 0000000..eb9689d --- /dev/null +++ b/packages/e2e-tests/tests/user-interface/user-interface-configuration.spec.js @@ -0,0 +1,169 @@ +const { test, expect } = require('../../fixtures/index'); + +test.describe('User interface page', () => { + test.beforeEach(async ({ userInterfacePage }) => { + await userInterfacePage.profileMenuButton.click(); + await userInterfacePage.adminMenuItem.click(); + await expect(userInterfacePage.page).toHaveURL(/\/admin-settings\/users/); + await userInterfacePage.userInterfaceDrawerItem.click(); + await expect(userInterfacePage.page).toHaveURL( + /\/admin-settings\/user-interface/ + ); + await userInterfacePage.page.waitForURL(/\/admin-settings\/user-interface/); + }); + + test.describe('checks if the shown values are used', async () => { + test('checks primary main color', async ({ userInterfacePage }) => { + await userInterfacePage.primaryMainColorInput.waitFor({ + state: 'attached', + }); + const initialPrimaryMainColor = + await userInterfacePage.primaryMainColorInput.inputValue(); + const initialRgbColor = userInterfacePage.hexToRgb( + initialPrimaryMainColor + ); + await expect(userInterfacePage.updateButton).toHaveCSS( + 'background-color', + initialRgbColor + ); + }); + + test('checks primary dark color', async ({ userInterfacePage }) => { + await userInterfacePage.primaryDarkColorInput.waitFor({ + state: 'attached', + }); + const initialPrimaryDarkColor = + await userInterfacePage.primaryDarkColorInput.inputValue(); + const initialRgbColor = userInterfacePage.hexToRgb( + initialPrimaryDarkColor + ); + await expect(userInterfacePage.appBar).toHaveCSS( + 'background-color', + initialRgbColor + ); + }); + }); + + test.describe( + 'fill fields and check if the inputs reflect them properly', + async () => { + test('fill primary main color and check the color input', async ({ + userInterfacePage, + }) => { + await userInterfacePage.primaryMainColorInput.fill('#FF5733'); + const rgbColor = userInterfacePage.hexToRgb('#FF5733'); + const button = await userInterfacePage.primaryMainColorButton; + const styleAttribute = await button.getAttribute('style'); + expect(styleAttribute).toEqual(`background-color: ${rgbColor};`); + }); + + test('fill primary dark color and check the color input', async ({ + userInterfacePage, + }) => { + await userInterfacePage.primaryDarkColorInput.fill('#12F63F'); + const rgbColor = userInterfacePage.hexToRgb('#12F63F'); + const button = await userInterfacePage.primaryDarkColorButton; + const styleAttribute = await button.getAttribute('style'); + expect(styleAttribute).toEqual(`background-color: ${rgbColor};`); + }); + + test('fill primary light color and check the color input', async ({ + userInterfacePage, + }) => { + await userInterfacePage.primaryLightColorInput.fill('#1D0BF5'); + const rgbColor = userInterfacePage.hexToRgb('#1D0BF5'); + const button = await userInterfacePage.primaryLightColorButton; + const styleAttribute = await button.getAttribute('style'); + expect(styleAttribute).toEqual(`background-color: ${rgbColor};`); + }); + } + ); + + test.describe('update form based on input values', async () => { + test('fill primary main color', async ({ userInterfacePage }) => { + await userInterfacePage.primaryMainColorInput.fill('#00adef'); + await userInterfacePage.updateButton.click(); + await userInterfacePage.snackbar.waitFor({ state: 'visible' }); + await userInterfacePage.screenshot({ + path: 'updated primary main color.png', + }); + }); + + test('fill primary dark color', async ({ userInterfacePage }) => { + await userInterfacePage.primaryDarkColorInput.fill('#222222'); + await userInterfacePage.updateButton.click(); + await userInterfacePage.snackbar.waitFor({ state: 'visible' }); + await userInterfacePage.screenshot({ + path: 'updated primary dark color.png', + }); + }); + + test.skip('fill primary light color', async ({ userInterfacePage }) => { + await userInterfacePage.primaryLightColorInput.fill('#f90707'); + await userInterfacePage.updateButton.click(); + await userInterfacePage.goToDashboardButton.click(); + await expect(userInterfacePage.page).toHaveURL('/flows'); + await userInterfacePage.flowRowCardActionArea.waitFor({ + state: 'visible', + }); + await userInterfacePage.flowRowCardActionArea.hover(); + await userInterfacePage.screenshot({ + path: 'updated primary light color.png', + }); + }); + + test('fill logo svg code', async ({ userInterfacePage }) => { + await userInterfacePage.logoSvgCodeInput + .fill(` + + A + `); + await userInterfacePage.updateButton.click(); + await userInterfacePage.snackbar.waitFor({ state: 'visible' }); + await userInterfacePage.screenshot({ + path: 'updated svg code.png', + }); + }); + }); + + test.describe( + 'update form based on input values and check if the inputs still reflect them', + async () => { + test('update primary main color and check color input', async ({ + userInterfacePage, + }) => { + await userInterfacePage.primaryMainColorInput.fill('#00adef'); + await userInterfacePage.updateButton.click(); + await userInterfacePage.snackbar.waitFor({ state: 'visible' }); + const rgbColor = userInterfacePage.hexToRgb('#00adef'); + const button = await userInterfacePage.primaryMainColorButton; + const styleAttribute = await button.getAttribute('style'); + expect(styleAttribute).toEqual(`background-color: ${rgbColor};`); + }); + + test('update primary dark color and check color input', async ({ + userInterfacePage, + }) => { + await userInterfacePage.primaryDarkColorInput.fill('#222222'); + await userInterfacePage.updateButton.click(); + await userInterfacePage.snackbar.waitFor({ state: 'visible' }); + const rgbColor = userInterfacePage.hexToRgb('#222222'); + const button = await userInterfacePage.primaryDarkColorButton; + const styleAttribute = await button.getAttribute('style'); + expect(styleAttribute).toEqual(`background-color: ${rgbColor};`); + }); + + test('update primary light color and check color input', async ({ + userInterfacePage, + }) => { + await userInterfacePage.primaryLightColorInput.fill('#f90707'); + await userInterfacePage.updateButton.click(); + await userInterfacePage.snackbar.waitFor({ state: 'visible' }); + const rgbColor = userInterfacePage.hexToRgb('#f90707'); + const button = await userInterfacePage.primaryLightColorButton; + const styleAttribute = await button.getAttribute('style'); + expect(styleAttribute).toEqual(`background-color: ${rgbColor};`); + }); + } + ); +}); diff --git a/packages/e2e-tests/tests/user-invitation/invitation.spec.js b/packages/e2e-tests/tests/user-invitation/invitation.spec.js new file mode 100644 index 0000000..2035f80 --- /dev/null +++ b/packages/e2e-tests/tests/user-invitation/invitation.spec.js @@ -0,0 +1,84 @@ +const { publicTest, expect } = require('../../fixtures/index'); +const { pgPool } = require('../../fixtures/postgres-config'); +const { DateTime } = require('luxon'); + +publicTest.describe('Accept invitation page', () => { + publicTest('should not be able to set the password if token is empty', async ({ acceptInvitationPage }) => { + await acceptInvitationPage.open(''); + await acceptInvitationPage.excpectSubmitButtonToBeDisabled(); + await acceptInvitationPage.fillPasswordField('something'); + await acceptInvitationPage.excpectSubmitButtonToBeDisabled(); + }); + + publicTest('should not be able to set the password if token is not in db', async ({ acceptInvitationPage }) => { + await acceptInvitationPage.open('abc'); + await acceptInvitationPage.acceptInvitation('something'); + await acceptInvitationPage.expectAlertToBeVisible(); + }); + + publicTest.describe('Accept invitation page - users', () => { + const expiredTokenDate = DateTime.now().minus({ days: 3 }).toISO(); + const token = (Math.random() + 1).toString(36).substring(2); + + publicTest('should not be able to set the password if token is expired', async ({ acceptInvitationPage, adminCreateUserPage }) => { + adminCreateUserPage.seed(Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER)); + const user = adminCreateUserPage.generateUser(); + + const queryRole = { + text: 'SELECT * FROM roles WHERE name = $1', + values: ['Admin'] + }; + + try { + const queryRoleIdResult = await pgPool.query(queryRole); + expect(queryRoleIdResult.rowCount).toEqual(1); + + const insertUser = { + text: 'INSERT INTO users (email, full_name, role_id, status, invitation_token, invitation_token_sent_at) VALUES ($1, $2, $3, $4, $5, $6)', + values: [user.email, user.fullName, queryRoleIdResult.rows[0].id, 'invited', token, expiredTokenDate], + }; + + const insertUserResult = await pgPool.query(insertUser); + expect(insertUserResult.rowCount).toBe(1); + expect(insertUserResult.command).toBe('INSERT'); + } catch (err) { + console.error(err.message); + throw err; + } + await acceptInvitationPage.open(token); + await acceptInvitationPage.acceptInvitation('something'); + await acceptInvitationPage.expectAlertToBeVisible(); + }); + + publicTest('should not be able to accept invitation if user was soft deleted', async ({ acceptInvitationPage, adminCreateUserPage }) => { + const dateNow = DateTime.now().toISO(); + const user = adminCreateUserPage.generateUser(); + + const queryRole = { + text: 'SELECT * FROM roles WHERE name = $1', + values: ['Admin'] + }; + + try { + const queryRoleIdResult = await pgPool.query(queryRole); + expect(queryRoleIdResult.rowCount).toEqual(1); + + const insertUser = { + text: 'INSERT INTO users (email, full_name, deleted_at, role_id, status, invitation_token, invitation_token_sent_at) VALUES ($1, $2, $3, $4, $5, $6, $7)', + values: [user.email, user.fullName, dateNow, queryRoleIdResult.rows[0].id, 'invited', token, dateNow], + }; + + const insertUserResult = await pgPool.query(insertUser); + expect(insertUserResult.rowCount).toBe(1); + expect(insertUserResult.command).toBe('INSERT'); + } catch (err) { + console.error(err.message); + throw err; + } + + await acceptInvitationPage.open(token); + await acceptInvitationPage.acceptInvitation('something'); + await acceptInvitationPage.expectAlertToBeVisible(); + }); + }); +}); diff --git a/packages/e2e-tests/yarn.lock b/packages/e2e-tests/yarn.lock new file mode 100644 index 0000000..22f7c30 --- /dev/null +++ b/packages/e2e-tests/yarn.lock @@ -0,0 +1,1145 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@eslint-community/eslint-utils@^4.2.0": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" + integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.6.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.57.1": + version "8.57.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" + integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== + +"@faker-js/faker@^8.2.0": + version "8.4.1" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.4.1.tgz#5d5e8aee8fce48f5e189bf730ebd1f758f491451" + integrity sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg== + +"@humanwhocodes/config-array@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" + integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== + dependencies: + "@humanwhocodes/object-schema" "^2.0.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" + integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@playwright/test@1.49.0": + version "1.49.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.49.0.tgz#74227385b58317ee076b86b56d0e1e1b25cff01e" + integrity sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw== + dependencies: + playwright "1.49.0" + +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.9.0: + version "8.14.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" + integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== + +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.0, ajv@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +arg@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0" + integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.6.0: + version "1.7.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" + integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +bytes@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" + integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colorette@2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" + integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +content-type@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +cross-spawn@^7.0.2: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +db-errors@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/db-errors/-/db-errors-0.2.3.tgz#a6a38952e00b20e790f2695a6446b3c65497ffa2" + integrity sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng== + +debug@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@^4.3.1, debug@^4.3.2: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dotenv@^16.3.1: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@^8.3.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz#3a06a662130807e2502fc3ff8b4143d8a0658e11" + integrity sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg== + +eslint-plugin-prettier@^4.0.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" + integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ== + dependencies: + prettier-linter-helpers "^1.0.0" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint@^8.13.0: + version "8.57.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" + integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.57.1" + "@humanwhocodes/config-array" "^0.13.0" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +esm@^3.2.25: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esquery@^1.4.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-uri@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.3.tgz#892a1c91802d5d7860de728f18608a0573142241" + integrity sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw== + +fastq@^1.6.0: + version "1.17.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" + integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + dependencies: + reusify "^1.0.4" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flatted@^3.2.9: + version "3.3.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" + integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== + +follow-redirects@^1.15.6: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + +form-data@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48" + integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +getopts@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4" + integrity sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +http-errors@1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +interpret@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" + integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== + +is-core-module@^2.13.0: + version "2.15.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37" + integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ== + dependencies: + hasown "^2.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +knex@^2.4.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/knex/-/knex-2.5.1.tgz#a6c6b449866cf4229f070c17411f23871ba52ef9" + integrity sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA== + dependencies: + colorette "2.0.19" + commander "^10.0.0" + debug "4.3.4" + escalade "^3.1.1" + esm "^3.2.25" + get-package-type "^0.1.0" + getopts "2.3.0" + interpret "^2.2.0" + lodash "^4.17.21" + pg-connection-string "2.6.1" + rechoir "^0.8.0" + resolve-from "^5.0.0" + tarn "^3.0.2" + tildify "2.0.0" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +luxon@^3.4.4: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + +micro@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/micro/-/micro-10.0.1.tgz#2601e02b0dacd2eaee77e9de18f12b2e595c5951" + integrity sha512-9uwZSsUrqf6+4FLLpiPj5TRWQv5w5uJrJwsx1LR/TjqvQmKC1XnGQ9OHrFwR3cbZ46YqPqxO/XJCOpWnqMPw2Q== + dependencies: + arg "4.1.0" + content-type "1.0.4" + raw-body "2.4.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +objection@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/objection/-/objection-3.1.5.tgz#53c32f6b6cba2958bc28cf723de96c2676da8286" + integrity sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ== + dependencies: + ajv "^8.17.1" + ajv-formats "^2.1.1" + db-errors "^0.2.3" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +pg-cloudflare@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" + integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== + +pg-connection-string@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.1.tgz#78c23c21a35dd116f48e12e23c0965e8d9e2cbfb" + integrity sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg== + +pg-connection-string@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" + integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec" + integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g== + +pg-protocol@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93" + integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ== + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^8.12.0: + version "8.13.1" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080" + integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ== + dependencies: + pg-connection-string "^2.7.0" + pg-pool "^3.7.0" + pg-protocol "^1.7.0" + pg-types "^2.1.0" + pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" + +pgpass@1.x: + version "1.0.5" + resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d" + integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug== + dependencies: + split2 "^4.1.0" + +playwright-core@1.49.0: + version "1.49.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.49.0.tgz#8e69ffed3f41855b854982f3632f2922c890afcb" + integrity sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA== + +playwright@1.49.0: + version "1.49.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.49.0.tgz#df6b9e05423377a99658202844a294a8afb95d0a" + integrity sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A== + dependencies: + playwright-core "1.49.0" + optionalDependencies: + fsevents "2.3.2" + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== + +postgres-date@~1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" + integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prettier-linter-helpers@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" + integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + dependencies: + fast-diff "^1.1.2" + +prettier@^2.5.1: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +raw-body@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c" + integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA== + dependencies: + bytes "3.1.0" + http-errors "1.7.3" + iconv-lite "0.4.24" + unpipe "1.0.0" + +rechoir@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" + integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== + dependencies: + resolve "^1.20.0" + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.20.0: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" + integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +split2@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + +"statuses@>= 1.5.0 < 2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tarn@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693" + integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ== + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +tildify@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" + integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== + +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" + integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/packages/web/.env-example b/packages/web/.env-example new file mode 100644 index 0000000..d5663a3 --- /dev/null +++ b/packages/web/.env-example @@ -0,0 +1,4 @@ +PORT=3001 +REACT_APP_BACKEND_URL=http://localhost:3000 +# HTTPS=true +REACT_APP_BASE_URL=http://localhost:3001 diff --git a/packages/web/.eslintignore b/packages/web/.eslintignore new file mode 100644 index 0000000..49a2382 --- /dev/null +++ b/packages/web/.eslintignore @@ -0,0 +1,4 @@ +node_modules +build +source +.eslintrc.js diff --git a/packages/web/.eslintrc.js b/packages/web/.eslintrc.js new file mode 100644 index 0000000..5013402 --- /dev/null +++ b/packages/web/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + extends: [ + 'react-app', + 'plugin:@tanstack/eslint-plugin-query/recommended', + 'prettier', + ], + rules: { + 'react/prop-types': 'warn', + }, +}; diff --git a/packages/web/.gitignore b/packages/web/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/packages/web/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000..b58e0af --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,46 @@ +# Getting Started with Create React App + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `yarn eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/packages/web/index.js b/packages/web/index.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/web/jsconfig.json b/packages/web/jsconfig.json new file mode 100644 index 0000000..5875dc5 --- /dev/null +++ b/packages/web/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "baseUrl": "src" + }, + "include": ["src"] +} diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..cd34f29 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,98 @@ +{ + "name": "@automatisch/web", + "version": "0.10.0", + "license": "See LICENSE file", + "description": "The open source Zapier alternative. Build workflow automation without spending time and money.", + "dependencies": { + "@casl/ability": "^6.5.0", + "@casl/react": "^3.1.0", + "@dagrejs/dagre": "^1.1.2", + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "@hookform/resolvers": "^2.8.8", + "@monaco-editor/react": "^4.6.0", + "@mui/icons-material": "^5.11.9", + "@mui/lab": "^5.0.0-alpha.120", + "@mui/material": "^5.11.10", + "@tanstack/react-query": "^5.24.1", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.1.0", + "@testing-library/user-event": "^12.1.10", + "axios": "^1.6.0", + "clipboard-copy": "^4.0.1", + "compare-versions": "^4.1.3", + "lodash": "^4.17.21", + "luxon": "^2.3.1", + "mui-color-input": "^2.0.0", + "notistack": "^3.0.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.45.2", + "react-intl": "^5.20.12", + "react-json-tree": "^0.16.2", + "react-router-dom": "^6.0.2", + "react-scripts": "5.0.0", + "react-window": "^1.8.9", + "reactflow": "^11.11.2", + "slate": "^0.94.1", + "slate-history": "^0.93.0", + "slate-react": "^0.94.2", + "slugify": "^1.6.6", + "uuid": "^9.0.0", + "web-vitals": "^1.0.1", + "yup": "^0.32.11" + }, + "main": "index.js", + "scripts": { + "dev": "react-scripts start", + "build": "react-scripts build", + "build:watch": "yarn nodemon --exec react-scripts build --watch 'src/**/*.ts' --watch 'public/**/*' --ext ts,html", + "test": "react-scripts test", + "eject": "react-scripts eject", + "lint": "eslint src --ext .js,.jsx", + "prepack": "yarn build" + }, + "files": [ + "/build" + ], + "contributors": [ + { + "name": "automatisch contributors", + "url": "https://github.com/automatisch/automatisch/graphs/contributors" + } + ], + "bugs": { + "url": "https://github.com/automatisch/automatisch/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/automatisch/automatisch.git" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.20.1", + "@tanstack/react-query-devtools": "^5.24.1", + "eslint-config-prettier": "^9.1.0", + "eslint-config-react-app": "^7.0.1", + "prettier": "^3.2.5" + }, + "eslintConfig": { + "extends": [ + "./.eslintrc.js" + ] + } +} diff --git a/packages/web/public/browser-tab.ico b/packages/web/public/browser-tab.ico new file mode 100644 index 0000000000000000000000000000000000000000..59ab8480ef7bffdf8d21b5ef9386dec3f73f51fd GIT binary patch literal 15406 zcmeI2dx%t39LIlC+WVp8rmnj)ch}8UDY48RNVHNa;Xa(19a5LdBDO$GVok^t><{TF z)E`D#X&49unEc^WK+`BV7bMBq>dhZH#2hN;xfA{zN zp7%LdBq68EkRd{D$vuNat`-r?%0D*_6a1$1g@1RiJ;!gJk3#S8lXc&?3WVyf9ZK=c#6$TNo!- z6{i-gO?Ryh-^ta+4pS+V6Fwq>vdUHTZPI;KNDTr%hd( zu9Mr)+r?P-F!^&iOGcEK*T)3!bX@F<0qxSj+iwyvDZYJlLffG8Qn3j;}&~uUrq`9Q{oCFZ!UyMMEK#V|)K#YKcz?`HE)qSY0?OAib!#-5+ zarUvl`I>wS=?TuCu6Ne`2^G^bQRp+^&6!ejP4xFRdMA~sl~(+G17FJb#)W;N?xFtR zjN|QgOWJThk*l%yow~;?-(M+>vj+#BNU|o8LEx?De|pYr_Q6a)$lho{7JQLAW&UIT zngG7LaUP^D?gL}@&ex?E@(A(Q`(1q3v#x@@6EABY@j8==wEeJ8SL{vaA%%F7@&!E0 z!CCK9?{d!hyD1lV{K^sHj}J?H{5VFtSZg}}<(~dR`phH9R_g1K;*b4xKK_5F-y`@8 z8VSx==2eU#Li`yoH~9Gf8DrzwKK={i&k*D{`sj!n|200g_b~U2^7CKcwR5kBj=wzh zM2J80<-J~ZsT&A!tGYRP-}*XRDMLOjQb zTkp2Fk#t?>ecl`~{!~GtEh*z1Kg?~wZ+@!uE_(m%rrgvdgP4yO(5F8J+aBWa>lgix zbAbG}gV6pkS}rDrX8d1FTBB=a@*Tv~P5uk%gxXn@fbsW;#Ync-K`D8~w@wD$|HQQ6axOlNEZ!#?1AxdJG9O;O zV>k8*>S~^sY#4Ozm~UkKejNMj=)Z1T|D5MNq?PVBKOVLcwC@Mt^X}_Oxz$596f*PA zE!a2CUto7g=c*?5m?mTP{FRcokT+rvUHkf#CfTrP&iu8@$A=$zx0L<;ByH~+i=I>H zIfU*Hc<0%6c(*qDqswG7 zY#KP$V{=E5UDtllT-%MVE&6;v6}I2S`r7h);q76>KplO_95{8E$Z5qkqAuI>6W@Fj zgI`;!R43b7Yh=8hZA9&_Nq>_iXVIQIbsa<;2Pk{*t@7<$W9?zA+DTfdbB9UqK+|-r z$=1qr{9i;`g^&8}$!7YluC+CPop!RC@?srh+2af!XdnA&FEN)o(Y?BUqoeCP&1W!% hI{SP39JimkVzwB87=ajp7=ajp7=ajp7=aQ5{sk>Zrr-bo literal 0 HcmV?d00001 diff --git a/packages/web/public/fonts/Inter-Bold.ttf b/packages/web/public/fonts/Inter-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fe23eeb9c93a377d0f4ab003f1f77b555d19b1d1 GIT binary patch literal 316584 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 #OJ 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ȇ@(g~$ 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 literal 0 HcmV?d00001 diff --git a/packages/web/public/fonts/Inter-Medium.ttf b/packages/web/public/fonts/Inter-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a01f3777a6fc284b7a720c0f8248a27066389ef9 GIT binary patch literal 315132 zcmd>{3Ajzw|M0(S?X!n-?sds<&AMdHJY`De%rlit*Gxz*k|ZQaMWm8sCL|H5Orgje zLX;sYN}^G@hWmcjK4)L_tKaY+-sgFr_ul8T*4k_QuJ8KpwfA0opS?wiNG6<+$lZ18 z)h}DV>>3eWoroNFw`kt_=)7a6M2)O0s^ph46$l{1e>k6gWyQwEyWNY9Gz_3HOX&vyAf z)S{Q>5MAJ8?;gqBOFo}`FX>%Kuhg4}e4BH=#y0Xu!QTA`56g7y>@Ol2dy5p=(eJ@- z$>AZbE{NPwf$hEhlZOqA#4CsVv?oaelKb~){O#aW5vPqvIP<^B5&SN zzrXqG{ycsElt;op`!QDN*v#5b&hDJC`frju&adR?&uep=z_GS14*mY-l@WRR#%E^J z5gn_;)b6n*lGS$$ihj6P@4+ggNJ_r}gHSL1OfsF!hHQL}O9s)M+nsb6q^Qx|dn(6vPA zI=TUFBi#nKoleH>rm-LWq<#hWRsAaNOuY_wquz}Bk=}y4L+`}htv|#4+^Q|gs%JIC zZDKuyJJ=e6`-n9Tcf7Rn$I0&?vs26| zhFiiZfm_XC?3_kUBivR_Pu$*4Z`=o*2XF^C18^U9hKc1o;ygllxHE$A80QJxxz0jS zuIEyxThJ|tTgUBy+u7}m`?&iA?o;klxKF!J<38g~!kywy!JX<(#hvD&2lrK%QFa%* zi*es@m*OsSm*cK3;-OD(xHp{e)}Vk zj}jgoW^}?YgkQj&9-fZ-es~?J8^W6i?+WiGygz&h_el5{?w4U|4u2c|7WaG@4Tpb< z&3F~`P%|^Jmxw&?#K*!)a@fDd!depS)3LB6rR?`&;m{3vY^h|U1HT@J_`b2QD{bt= zSlAQKPKbrWk`y{03&#=O6AQ=R5YC{ItYxwI1gT)5Z@>Ob60rux!na7g)jbx@d_A7& zF41ZhjaN$YSmk12Emid&v9Kle_1m#?vid~S~a&&R@fB%ch5h4XS)iH$F^BublD{H+{eYRAG!9CZrB!ugRWM=V@`kdUjN zBuh7WfLsk_FyVgEhwba}^<)_FgGi|@50cvbPdWcF{jc){v8|mem2h13VpUJrSMT9Ty>3?!}(7+ojH-SDu#-J4JfsYA$L->-WxHJI`PNg0eR zrk)%OnM*bDYd9+u%CMXc}>W)X;S;MZTi-o7KTVyUt%M{ zpqpyy=F2mb5@zI$)P2a=gfb8NwcIE}lbcEgd7JyvnwADob^xWW$D|N5?1t^0zAS_O zQtAi&vUMquf)xFc*ywzS->W{9Gc5#~G(9rbkmBnx1>HsEAMVR+MkEQ{8BLp>rLbir zGrCS9&XgYD_t!`p9oeqL4kG72YyWz`jLr6f!IZesh8y~=80{Nt9gJqGN@@O6kj9Lc zv5gcd1xCxI{5E@$-W-c)!v1aS(MVgL6eDkvv?kP((8K7#*oWzf$r0U;dyvc6hZ(zQ zpG@0PYchFTv)7wZyDs_ln$4cljk3`iN|37^TSfn*nUt7Tjja8l6mAc+*oxXs%n;lp+B0Ju?NN%a&mjNx zQA%QOYv@ZAwTBy1qIx#6N9RXVm)WNV`Ln4R6=O?*1lLQJyvZW|tkd#HTL!IG;ff$? zK_%HvmWsrckxH~t1`Xbrs}-Zz6sjSqu?f?oo7-qboY_;%TtDcBTuC=cQU@IjWS%m1 z7|f>SXw48EVPi4Z+enfY7y8oNVG@2$=8*Bb|Yth z#wE~FX<80?P)sDQpt&yPN^GUn|6ii|e_rpkJ?_f1l28#`I0oDP{d)d+`M8MGzY({b zoVDP42V>h{^2j=f$(R9Lo&d7Uiy12EF<#m^3% zk;mAc3tNaQ3?C4lt$)Av9sZ_hKH0z`#ŹD@)<7ltOtn$SSm;LMTrPEA?sR43d= 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�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 literal 0 HcmV?d00001 diff --git a/packages/web/public/fonts/Inter-Regular.ttf b/packages/web/public/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5e4851f0ab7e0268da6ce903306e2f871ee19821 GIT binary patch literal 310252 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 z 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%0D1lG~DE2ff7BeoTnFxKM!4172W=IjHdV9zf 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 + + + + + + + + + + + + + + + +

+ + + diff --git a/packages/web/public/robots.txt b/packages/web/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/packages/web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/packages/web/src/adminSettingsRoutes.jsx b/packages/web/src/adminSettingsRoutes.jsx new file mode 100644 index 0000000..3e842d8 --- /dev/null +++ b/packages/web/src/adminSettingsRoutes.jsx @@ -0,0 +1,117 @@ +import { Route, Navigate } from 'react-router-dom'; + +import Users from 'pages/Users'; +import EditUser from 'pages/EditUser'; +import CreateUser from 'pages/CreateUser'; +import Roles from 'pages/Roles/index.ee'; +import CreateRole from 'pages/CreateRole/index.ee'; +import EditRole from 'pages/EditRole/index.ee'; +import Authentication from 'pages/Authentication'; +import UserInterface from 'pages/UserInterface'; +import * as URLS from 'config/urls'; +import Can from 'components/Can'; +import AdminApplications from 'pages/AdminApplications'; +import AdminApplication from 'pages/AdminApplication'; +// TODO: consider introducing redirections to `/` as fallback +export default ( + <> + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + + + + + } + /> + + + + + } + /> + + + + + } + /> + + } + /> + +); diff --git a/packages/web/src/components/AcceptInvitationForm/index.jsx b/packages/web/src/components/AcceptInvitationForm/index.jsx new file mode 100644 index 0000000..bbd35c0 --- /dev/null +++ b/packages/web/src/components/AcceptInvitationForm/index.jsx @@ -0,0 +1,138 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Paper from '@mui/material/Paper'; +import Alert from '@mui/material/Alert'; +import Typography from '@mui/material/Typography'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; +import * as React from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import * as yup from 'yup'; +import Form from 'components/Form'; +import TextField from 'components/TextField'; +import * as URLS from 'config/urls'; +import useAcceptInvitation from 'hooks/useAcceptInvitation'; +import useFormatMessage from 'hooks/useFormatMessage'; + +const validationSchema = yup.object().shape({ + password: yup.string().required('acceptInvitationForm.mandatoryInput'), + confirmPassword: yup + .string() + .required('acceptInvitationForm.mandatoryInput') + .oneOf([yup.ref('password')], 'acceptInvitationForm.passwordsMustMatch'), +}); + +export default function ResetPasswordForm() { + const enqueueSnackbar = useEnqueueSnackbar(); + const formatMessage = useFormatMessage(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const acceptInvitation = useAcceptInvitation(); + const token = searchParams.get('token'); + + const handleSubmit = async (values) => { + await acceptInvitation.mutateAsync({ + password: values.password, + token, + }); + + enqueueSnackbar(formatMessage('acceptInvitationForm.invitationAccepted'), { + variant: 'success', + SnackbarProps: { + 'data-test': 'snackbar-accept-invitation-success', + }, + }); + + navigate(URLS.LOGIN); + }; + + return ( + + theme.palette.text.disabled, + pb: 2, + mb: 2, + }} + gutterBottom + data-test="accept-invitation-form-title" + > + {formatMessage('acceptInvitationForm.title')} + + +
( + <> + + + + {acceptInvitation.isError && ( + + {formatMessage('acceptInvitationForm.invalidToken')} + + )} + + + {formatMessage('acceptInvitationForm.submit')} + + + )} + /> + + ); +} diff --git a/packages/web/src/components/AccountDropdownMenu/index.jsx b/packages/web/src/components/AccountDropdownMenu/index.jsx new file mode 100644 index 0000000..0bfb2ad --- /dev/null +++ b/packages/web/src/components/AccountDropdownMenu/index.jsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import MenuItem from '@mui/material/MenuItem'; +import Menu from '@mui/material/Menu'; +import { Link } from 'react-router-dom'; + +import Can from 'components/Can'; +import * as URLS from 'config/urls'; +import useAuthentication from 'hooks/useAuthentication'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useRevokeAccessToken from 'hooks/useRevokeAccessToken'; + +function AccountDropdownMenu(props) { + const formatMessage = useFormatMessage(); + const authentication = useAuthentication(); + const token = authentication.token; + const navigate = useNavigate(); + const revokeAccessTokenMutation = useRevokeAccessToken(token); + const { open, onClose, anchorEl, id } = props; + + const logout = async () => { + await revokeAccessTokenMutation.mutateAsync(); + + authentication.removeToken(); + onClose(); + navigate(URLS.LOGIN); + }; + + return ( + + + {formatMessage('accountDropdownMenu.settings')} + + + + + {formatMessage('accountDropdownMenu.adminSettings')} + + + + + {formatMessage('accountDropdownMenu.logout')} + + + ); +} + +AccountDropdownMenu.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + anchorEl: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), + id: PropTypes.string.isRequired, +}; + +export default AccountDropdownMenu; diff --git a/packages/web/src/components/AddAppConnection/index.jsx b/packages/web/src/components/AddAppConnection/index.jsx new file mode 100644 index 0000000..0744dd8 --- /dev/null +++ b/packages/web/src/components/AddAppConnection/index.jsx @@ -0,0 +1,191 @@ +import PropTypes from 'prop-types'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Alert from '@mui/material/Alert'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import * as React from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { AppPropType } from 'propTypes/propTypes'; +import AppOAuthClientsDialog from 'components/OAuthClientsDialog/index.ee'; +import InputCreator from 'components/InputCreator'; +import * as URLS from 'config/urls'; +import useAuthenticateApp from 'hooks/useAuthenticateApp.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; +import { generateExternalLink } from 'helpers/translationValues'; +import { Form } from './style'; +import useAppAuth from 'hooks/useAppAuth'; +import { useQueryClient } from '@tanstack/react-query'; + +function AddAppConnection(props) { + const { application, connectionId, onClose } = props; + const { name, authDocUrl, key } = application; + const { data: auth } = useAppAuth(key); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const formatMessage = useFormatMessage(); + const [errorMessage, setErrorMessage] = React.useState(null); + const [errorDetails, setErrorDetails] = React.useState(null); + const [inProgress, setInProgress] = React.useState(false); + const hasConnection = Boolean(connectionId); + const useShared = searchParams.get('shared') === 'true'; + const oauthClientId = searchParams.get('oauthClientId') || undefined; + const { authenticate } = useAuthenticateApp({ + appKey: key, + connectionId, + oauthClientId, + useShared: !!oauthClientId, + }); + const queryClient = useQueryClient(); + const enqueueSnackbar = useEnqueueSnackbar(); + + React.useEffect(function relayProviderData() { + if (window.opener) { + window.opener.postMessage({ + source: 'automatisch', + payload: { search: window.location.search, hash: window.location.hash }, + }); + + window.close(); + } + }, []); + + React.useEffect( + function initiateSharedAuthenticationForGivenOAuthClient() { + if (!oauthClientId) return; + + if (!authenticate) return; + + const asyncAuthenticate = async () => { + try { + await authenticate(); + navigate(URLS.APP_CONNECTIONS(key)); + } catch (error) { + enqueueSnackbar(error?.message || formatMessage('genericError'), { + variant: 'error', + }); + } + }; + + asyncAuthenticate(); + }, + [oauthClientId, authenticate, key, navigate], + ); + + const handleClientClick = (oauthClientId) => + navigate(URLS.APP_ADD_CONNECTION_WITH_OAUTH_CLIENT_ID(key, oauthClientId)); + + const handleOAuthClientsDialogClose = () => + navigate(URLS.APP_CONNECTIONS(key)); + + const submitHandler = React.useCallback( + async (data) => { + if (!authenticate) return; + setInProgress(true); + setErrorMessage(null); + setErrorDetails(null); + try { + const response = await authenticate({ + fields: data, + }); + + await queryClient.invalidateQueries({ + queryKey: ['apps', key, 'connections'], + }); + + onClose(response); + } catch (err) { + const error = err; + console.log(error); + + setErrorMessage(error.message); + setErrorDetails(error?.response?.data?.errors); + } finally { + setInProgress(false); + } + }, + [authenticate, key, onClose, queryClient], + ); + + if (useShared) + return ( + + ); + + if (oauthClientId) return ; + + return ( + onClose()} + data-test="add-app-connection-dialog" + > + + {hasConnection + ? formatMessage('app.reconnectConnection') + : formatMessage('app.addConnection')} + + + {authDocUrl && ( + + {formatMessage('addAppConnection.callToDocs', { + appName: name, + docsLink: generateExternalLink(authDocUrl), + })} + + )} + + {(errorMessage || errorDetails) && ( + + {!errorDetails && errorMessage} + {errorDetails && ( +
+              {JSON.stringify(errorDetails, null, 2)}
+            
+ )} +
+ )} + + + + + {auth?.data?.fields?.map((field) => ( + + ))} + + + {formatMessage('addAppConnection.submit')} + + + + +
+ ); +} + +AddAppConnection.propTypes = { + onClose: PropTypes.func.isRequired, + application: AppPropType.isRequired, + connectionId: PropTypes.string, +}; + +export default AddAppConnection; diff --git a/packages/web/src/components/AddAppConnection/style.js b/packages/web/src/components/AddAppConnection/style.js new file mode 100644 index 0000000..2d2c434 --- /dev/null +++ b/packages/web/src/components/AddAppConnection/style.js @@ -0,0 +1,8 @@ +import { styled } from '@mui/material/styles'; +import BaseForm from 'components/Form'; +export const Form = styled(BaseForm)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + paddingTop: theme.spacing(1), +})); diff --git a/packages/web/src/components/AddNewAppConnection/index.jsx b/packages/web/src/components/AddNewAppConnection/index.jsx new file mode 100644 index 0000000..e69c18e --- /dev/null +++ b/packages/web/src/components/AddNewAppConnection/index.jsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import debounce from 'lodash/debounce'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import Dialog from '@mui/material/Dialog'; +import SearchIcon from '@mui/icons-material/Search'; +import InputAdornment from '@mui/material/InputAdornment'; +import CircularProgress from '@mui/material/CircularProgress'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import FormControl from '@mui/material/FormControl'; +import Box from '@mui/material/Box'; + +import * as URLS from 'config/urls'; +import AppIcon from 'components/AppIcon'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useLazyApps from 'hooks/useLazyApps'; + +function createConnectionOrFlow(appKey, supportsConnections = false) { + if (!supportsConnections) { + return URLS.CREATE_FLOW; + } + + return URLS.APP_ADD_CONNECTION(appKey); +} + +function AddNewAppConnection(props) { + const { onClose } = props; + const theme = useTheme(); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('sm')); + const formatMessage = useFormatMessage(); + const [appName, setAppName] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + + const { data: apps, mutate } = useLazyApps( + { appName }, + { + onSuccess: () => { + setIsLoading(false); + }, + }, + ); + + const fetchData = React.useMemo(() => debounce(mutate, 300), [mutate]); + + React.useEffect(() => { + setIsLoading(true); + + fetchData(appName); + + return () => { + fetchData.cancel(); + }; + }, [fetchData, appName]); + + return ( + + {formatMessage('apps.addNewAppConnection')} + + + + + {formatMessage('apps.searchApp')} + + + setAppName(event.target.value)} + endAdornment={ + + theme.palette.primary.main }} + /> + + } + label={formatMessage('apps.searchApp')} + inputProps={{ + 'data-test': 'search-for-app-text-field', + }} + /> + + + + + + {isLoading && ( + + )} + + {!isLoading && + apps?.data.map((app) => ( + + + + + + + theme.palette.text.primary }, + }} + /> + + + ))} + + + + ); +} + +AddNewAppConnection.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +export default AddNewAppConnection; diff --git a/packages/web/src/components/AdminApplicationCreateOAuthClient/index.jsx b/packages/web/src/components/AdminApplicationCreateOAuthClient/index.jsx new file mode 100644 index 0000000..946382d --- /dev/null +++ b/packages/web/src/components/AdminApplicationCreateOAuthClient/index.jsx @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useMemo } from 'react'; + +import { AppPropType } from 'propTypes/propTypes'; +import useAdminCreateAppConfig from 'hooks/useAdminCreateAppConfig'; +import useAppConfig from 'hooks/useAppConfig.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useAdminCreateOAuthClient from 'hooks/useAdminCreateOAuthClient.ee'; +import AdminApplicationOAuthClientDialog from 'components/AdminApplicationOAuthClientDialog'; +import useAppAuth from 'hooks/useAppAuth'; + +function AdminApplicationCreateOAuthClient(props) { + const { appKey, onClose } = props; + const { data: auth } = useAppAuth(appKey); + const formatMessage = useFormatMessage(); + + const { data: appConfig, isLoading: isAppConfigLoading } = + useAppConfig(appKey); + + const { + mutateAsync: createAppConfig, + isPending: isCreateAppConfigPending, + error: createAppConfigError, + } = useAdminCreateAppConfig(props.appKey); + + const { + mutateAsync: createOAuthClient, + isPending: isCreateOAuthClientPending, + error: createOAuthClientError, + } = useAdminCreateOAuthClient(appKey); + + const submitHandler = async (values) => { + let appConfigKey = appConfig?.data?.key; + + if (!appConfigKey) { + const { data: appConfigData } = await createAppConfig({ + useOnlyPredefinedAuthClients: false, + disabled: false, + }); + + appConfigKey = appConfigData.key; + } + + const { name, active, ...formattedAuthDefaults } = values; + + await createOAuthClient({ + appKey, + name, + active, + formattedAuthDefaults, + }); + + onClose(); + }; + + const getAuthFieldsDefaultValues = useCallback(() => { + if (!auth?.data?.fields) { + return {}; + } + + const defaultValues = {}; + + auth.data.fields.forEach((field) => { + if (field.value || field.type !== 'string') { + defaultValues[field.key] = field.value; + } else if (field.type === 'string') { + defaultValues[field.key] = ''; + } + }); + + return defaultValues; + }, [auth?.data?.fields]); + + const defaultValues = useMemo( + () => ({ + name: '', + active: false, + ...getAuthFieldsDefaultValues(), + }), + [getAuthFieldsDefaultValues], + ); + + return ( + + ); +} + +AdminApplicationCreateOAuthClient.propTypes = { + appKey: PropTypes.string.isRequired, + application: AppPropType.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default AdminApplicationCreateOAuthClient; diff --git a/packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx b/packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx new file mode 100644 index 0000000..9bb3895 --- /dev/null +++ b/packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Alert from '@mui/material/Alert'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { FieldPropType } from 'propTypes/propTypes'; +import useFormatMessage from 'hooks/useFormatMessage'; +import InputCreator from 'components/InputCreator'; +import Switch from 'components/Switch'; +import TextField from 'components/TextField'; +import { Form } from './style'; + +function AdminApplicationOAuthClientDialog(props) { + const { + error, + onClose, + title, + loading, + submitHandler, + authFields, + submitting, + defaultValues, + disabled = false, + } = props; + const formatMessage = useFormatMessage(); + return ( + + {title} + {error && ( + + {error.message} + + )} + + {loading ? ( + + ) : ( + +
( + <> + + + {authFields?.map((field) => ( + + ))} + + {formatMessage('oauthClient.buttonSubmit')} + + + )} + > +
+ )} +
+
+ ); +} + +AdminApplicationOAuthClientDialog.propTypes = { + error: PropTypes.shape({ + message: PropTypes.string, + }), + onClose: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + loading: PropTypes.bool.isRequired, + submitHandler: PropTypes.func.isRequired, + authFields: PropTypes.arrayOf(FieldPropType), + submitting: PropTypes.bool.isRequired, + defaultValues: PropTypes.object.isRequired, + disabled: PropTypes.bool, +}; + +export default AdminApplicationOAuthClientDialog; diff --git a/packages/web/src/components/AdminApplicationOAuthClientDialog/style.js b/packages/web/src/components/AdminApplicationOAuthClientDialog/style.js new file mode 100644 index 0000000..2d2c434 --- /dev/null +++ b/packages/web/src/components/AdminApplicationOAuthClientDialog/style.js @@ -0,0 +1,8 @@ +import { styled } from '@mui/material/styles'; +import BaseForm from 'components/Form'; +export const Form = styled(BaseForm)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + paddingTop: theme.spacing(1), +})); diff --git a/packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/index.jsx b/packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/index.jsx new file mode 100644 index 0000000..6072968 --- /dev/null +++ b/packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/index.jsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Alert from '@mui/material/Alert'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import CloseIcon from '@mui/icons-material/Close'; + +import { FieldPropType } from 'propTypes/propTypes'; +import useFormatMessage from 'hooks/useFormatMessage'; +import InputCreator from 'components/InputCreator'; +import Switch from 'components/Switch'; +import TextField from 'components/TextField'; +import { Form } from './style'; + +function AdminApplicationOAuthClientUpdateDialog(props) { + const { + error, + onClose, + title, + loading, + authFields, + defaultValues, + disabled = false, + submitAuthDefaults, + submitBasicData, + submittingBasicData, + submittingAuthDefaults, + } = props; + const formatMessage = useFormatMessage(); + const { name, active, ...formattedAuthDefaults } = defaultValues; + + return ( + + + + + {title} + {error && ( + + {error.message} + + )} + + {loading ? ( + + ) : ( + + +
( + <> + + + + {formatMessage('oauthClient.buttonSubmit')} + + + )} + /> + {authFields?.length > 0 && ( + <> + + ( + <> + {authFields?.map((field) => ( + + ))} + + {formatMessage('oauthClient.buttonSubmit')} + + + )} + /> + + )} + + + )} + +
+ ); +} + +AdminApplicationOAuthClientUpdateDialog.propTypes = { + error: PropTypes.shape({ + message: PropTypes.string, + }), + onClose: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + loading: PropTypes.bool.isRequired, + submitAuthDefaults: PropTypes.func.isRequired, + submitBasicData: PropTypes.func.isRequired, + authFields: PropTypes.arrayOf(FieldPropType), + submittingBasicData: PropTypes.bool.isRequired, + submittingAuthDefaults: PropTypes.bool.isRequired, + defaultValues: PropTypes.object.isRequired, + disabled: PropTypes.bool, +}; + +export default AdminApplicationOAuthClientUpdateDialog; diff --git a/packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/style.js b/packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/style.js new file mode 100644 index 0000000..2d2c434 --- /dev/null +++ b/packages/web/src/components/AdminApplicationOAuthClientUpdateDialog/style.js @@ -0,0 +1,8 @@ +import { styled } from '@mui/material/styles'; +import BaseForm from 'components/Form'; +export const Form = styled(BaseForm)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + paddingTop: theme.spacing(1), +})); diff --git a/packages/web/src/components/AdminApplicationOAuthClients/index.jsx b/packages/web/src/components/AdminApplicationOAuthClients/index.jsx new file mode 100644 index 0000000..ae41e8a --- /dev/null +++ b/packages/web/src/components/AdminApplicationOAuthClients/index.jsx @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import CircularProgress from '@mui/material/CircularProgress'; +import Stack from '@mui/material/Stack'; +import Card from '@mui/material/Card'; +import CardActionArea from '@mui/material/CardActionArea'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import Button from '@mui/material/Button'; + +import NoResultFound from 'components/NoResultFound'; +import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useAdminOAuthClients from 'hooks/useAdminOAuthClients'; + +function AdminApplicationOAuthClients(props) { + const { appKey } = props; + const formatMessage = useFormatMessage(); + const { data: appOAuthClients, isLoading } = useAdminOAuthClients(appKey); + + if (isLoading) + return ; + + if (!appOAuthClients?.data.length) { + return ( + + ); + } + + const sortedOAuthClients = appOAuthClients.data.slice().sort((a, b) => { + if (a.id < b.id) { + return -1; + } + if (a.id > b.id) { + return 1; + } + return 0; + }); + + return ( +
+ {sortedOAuthClients.map((client) => ( + + + + + + {client.name} + + + + + + + ))} + + + + + +
+ ); +} + +AdminApplicationOAuthClients.propTypes = { + appKey: PropTypes.string.isRequired, +}; + +export default AdminApplicationOAuthClients; diff --git a/packages/web/src/components/AdminApplicationSettings/index.jsx b/packages/web/src/components/AdminApplicationSettings/index.jsx new file mode 100644 index 0000000..99b46a7 --- /dev/null +++ b/packages/web/src/components/AdminApplicationSettings/index.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import { useMemo } from 'react'; +import useAppConfig from 'hooks/useAppConfig.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import Form from 'components/Form'; +import { Switch } from './style'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; +import useAdminCreateAppConfig from 'hooks/useAdminCreateAppConfig'; +import useAdminUpdateAppConfig from 'hooks/useAdminUpdateAppConfig'; + +function AdminApplicationSettings(props) { + const formatMessage = useFormatMessage(); + const enqueueSnackbar = useEnqueueSnackbar(); + + const { data: appConfig, isLoading: loading } = useAppConfig(props.appKey); + + const { mutateAsync: createAppConfig, isPending: isCreateAppConfigPending } = + useAdminCreateAppConfig(props.appKey); + + const { mutateAsync: updateAppConfig, isPending: isUpdateAppConfigPending } = + useAdminUpdateAppConfig(props.appKey); + + const handleSubmit = async (values) => { + try { + if (!appConfig?.data) { + await createAppConfig(values); + } else { + await updateAppConfig(values); + } + + enqueueSnackbar(formatMessage('adminAppsSettings.successfullySaved'), { + variant: 'success', + SnackbarProps: { + 'data-test': 'snackbar-save-admin-apps-settings-success', + }, + }); + } catch (error) { + throw new Error('Failed while saving!'); + } + }; + + const defaultValues = useMemo( + () => ({ + useOnlyPredefinedAuthClients: + appConfig?.data?.useOnlyPredefinedAuthClients || false, + disabled: appConfig?.data?.disabled || false, + }), + [appConfig?.data], + ); + + return ( + ( + + + + + + + + + + + + + {formatMessage('adminAppsSettings.save')} + + + + )} + > + ); +} + +AdminApplicationSettings.propTypes = { + appKey: PropTypes.string.isRequired, +}; + +export default AdminApplicationSettings; diff --git a/packages/web/src/components/AdminApplicationSettings/style.js b/packages/web/src/components/AdminApplicationSettings/style.js new file mode 100644 index 0000000..7f42ed8 --- /dev/null +++ b/packages/web/src/components/AdminApplicationSettings/style.js @@ -0,0 +1,6 @@ +import { styled } from '@mui/material/styles'; +import SwitchBase from 'components/Switch'; +export const Switch = styled(SwitchBase)` + justify-content: space-between; + margin: 0; +`; diff --git a/packages/web/src/components/AdminApplicationUpdateOAuthClient/index.jsx b/packages/web/src/components/AdminApplicationUpdateOAuthClient/index.jsx new file mode 100644 index 0000000..96844ae --- /dev/null +++ b/packages/web/src/components/AdminApplicationUpdateOAuthClient/index.jsx @@ -0,0 +1,124 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { AppPropType } from 'propTypes/propTypes'; +import useFormatMessage from 'hooks/useFormatMessage'; +import AdminApplicationOAuthClientUpdateDialog from 'components/AdminApplicationOAuthClientUpdateDialog'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; +import useAdminOAuthClient from 'hooks/useAdminOAuthClient.ee'; +import useAdminUpdateOAuthClient from 'hooks/useAdminUpdateOAuthClient.ee'; +import useAppAuth from 'hooks/useAppAuth'; + +function AdminApplicationUpdateOAuthClient(props) { + const { application, onClose } = props; + const formatMessage = useFormatMessage(); + const { clientId } = useParams(); + const enqueueSnackbar = useEnqueueSnackbar(); + const [defaultValues, setDefaultValues] = useState({ + name: '', + active: false, + }); + + const { data: adminOAuthClient, isLoading: isAdminOAuthClientLoading } = + useAdminOAuthClient(application.key, clientId); + + const { data: auth } = useAppAuth(application.key); + + const { mutateAsync: updateOAuthClient, error: updateOAuthClientError } = + useAdminUpdateOAuthClient(application.key, clientId); + const [basicDataUpdatePending, setBasicDataUpdatePending] = useState(false); + const [authDefaultsUpdatePending, setAuthDefaultsUpdatePending] = + useState(false); + + const authFields = auth?.data?.fields; + + const handleUpdateAuthDefaults = async (values) => { + if (!adminOAuthClient) { + return; + } + const { name, active, ...formattedAuthDefaults } = values; + + setAuthDefaultsUpdatePending(true); + await updateOAuthClient({ + formattedAuthDefaults, + }).finally(() => { + setAuthDefaultsUpdatePending(false); + }); + setDefaultValues((prev) => ({ + name: prev.name, + active: prev.active, + ...formattedAuthDefaults, + })); + + enqueueSnackbar(formatMessage('updateOAuthClient.success'), { + variant: 'success', + }); + }; + + const handleUpdateBasicData = async (values) => { + if (!adminOAuthClient) { + return; + } + const { name, active } = values; + + setBasicDataUpdatePending(true); + await updateOAuthClient({ + name, + active, + }).finally(() => { + setBasicDataUpdatePending(false); + }); + + enqueueSnackbar(formatMessage('updateOAuthClient.success'), { + variant: 'success', + }); + }; + + const authFieldsDefaultValue = useMemo(() => { + if (!authFields) { + return {}; + } + + const defaultValues = {}; + authFields.forEach((field) => { + if (field.value || field.type !== 'string') { + defaultValues[field.key] = field.value; + } else if (field.type === 'string') { + defaultValues[field.key] = ''; + } + }); + return defaultValues; + }, [auth?.fields]); + + useEffect(() => { + setDefaultValues({ + name: adminOAuthClient?.data?.name || '', + active: adminOAuthClient?.data?.active || false, + ...authFieldsDefaultValue, + }); + }, [adminOAuthClient, authFieldsDefaultValue]); + + return ( + + ); +} + +AdminApplicationUpdateOAuthClient.propTypes = { + application: AppPropType.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default AdminApplicationUpdateOAuthClient; diff --git a/packages/web/src/components/AdminSettingsLayout/Footer/index.jsx b/packages/web/src/components/AdminSettingsLayout/Footer/index.jsx new file mode 100644 index 0000000..8bfe8bc --- /dev/null +++ b/packages/web/src/components/AdminSettingsLayout/Footer/index.jsx @@ -0,0 +1,35 @@ +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; + +import useFormatMessage from 'hooks/useFormatMessage'; +import useVersion from 'hooks/useVersion'; + +const Footer = () => { + const version = useVersion(); + const formatMessage = useFormatMessage(); + + return ( + typeof version?.version === 'string' && ( + + + + + {formatMessage('adminSettingsFooter.version', { + version: version.version, + })} + + + + ) + ); +}; + +export default Footer; diff --git a/packages/web/src/components/AdminSettingsLayout/index.jsx b/packages/web/src/components/AdminSettingsLayout/index.jsx new file mode 100644 index 0000000..4570edc --- /dev/null +++ b/packages/web/src/components/AdminSettingsLayout/index.jsx @@ -0,0 +1,127 @@ +import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import GroupIcon from '@mui/icons-material/Group'; +import GroupsIcon from '@mui/icons-material/Groups'; +import LockIcon from '@mui/icons-material/LockPerson'; +import BrushIcon from '@mui/icons-material/Brush'; +import AppsIcon from '@mui/icons-material/Apps'; +import { Outlet } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Toolbar from '@mui/material/Toolbar'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import * as React from 'react'; + +import AppBar from 'components/AppBar'; +import Drawer from 'components/Drawer'; +import Can from 'components/Can'; +import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; + +import Footer from './Footer'; + +function createDrawerLinks({ + canReadRole, + canReadUser, + canUpdateConfig, + canManageSamlAuthProvider, + canUpdateApp, +}) { + const items = [ + canReadUser + ? { + Icon: GroupIcon, + primary: 'adminSettingsDrawer.users', + to: URLS.USERS, + dataTest: 'users-drawer-link', + } + : null, + canReadRole + ? { + Icon: GroupsIcon, + primary: 'adminSettingsDrawer.roles', + to: URLS.ROLES, + dataTest: 'roles-drawer-link', + } + : null, + canUpdateConfig + ? { + Icon: BrushIcon, + primary: 'adminSettingsDrawer.userInterface', + to: URLS.USER_INTERFACE, + dataTest: 'user-interface-drawer-link', + } + : null, + canManageSamlAuthProvider + ? { + Icon: LockIcon, + primary: 'adminSettingsDrawer.authentication', + to: URLS.AUTHENTICATION, + dataTest: 'authentication-drawer-link', + } + : null, + canUpdateApp + ? { + Icon: AppsIcon, + primary: 'adminSettingsDrawer.apps', + to: URLS.ADMIN_APPS, + dataTest: 'apps-drawer-link', + } + : null, + ].filter(Boolean); + return items; +} + +function SettingsLayout() { + const theme = useTheme(); + const formatMessage = useFormatMessage(); + const currentUserAbility = useCurrentUserAbility(); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); + const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); + const openDrawer = () => setDrawerOpen(true); + const closeDrawer = () => setDrawerOpen(false); + const drawerLinks = createDrawerLinks({ + canReadUser: currentUserAbility.can('read', 'User'), + canReadRole: currentUserAbility.can('read', 'Role'), + canUpdateConfig: currentUserAbility.can('update', 'Config'), + canManageSamlAuthProvider: + currentUserAbility.can('read', 'SamlAuthProvider') && + currentUserAbility.can('update', 'SamlAuthProvider') && + currentUserAbility.can('create', 'SamlAuthProvider'), + canUpdateApp: currentUserAbility.can('update', 'App'), + }); + const drawerBottomLinks = [ + { + Icon: ArrowBackIosNewIcon, + primary: formatMessage('adminSettingsDrawer.goBack'), + to: '/', + dataTest: 'go-back-drawer-link', + }, + ]; + return ( + + + + + + + +