Compare commits

...

69 Commits

Author SHA1 Message Date
Faruk AYDIN
95c1d7c02c feat: Implement draft design for execution filters 2023-05-27 21:19:28 +02:00
Ali BARIN
93a2e2151e fix: early exit upon failed actions 2023-05-27 21:16:24 +02:00
Ömer Faruk Aydın
663a1ed9d4 Merge pull request #1108 from automatisch/docs/filter
docs: Add filter connection and actions
2023-05-24 15:42:04 +02:00
Ali BARIN
4f46c55c85 feat: add LOG_LEVEL env. var. 2023-05-24 15:35:38 +02:00
Ali BARIN
9701c98af9 feat: error log errors in action and triggers 2023-05-24 15:35:38 +02:00
Ali BARIN
aabf2a1c79 feat: debug log incoming webhook requests 2023-05-24 15:35:38 +02:00
Ali BARIN
29539b090e feat: cover arrays in error handling 2023-05-24 15:35:38 +02:00
Faruk AYDIN
1c80677ac3 docs: Add filter connection and actions 2023-05-24 15:23:38 +02:00
Ali BARIN
ad419855e9 feat(webhook/handler): use UUID for internal ids 2023-05-24 12:59:48 +02:00
Ömer Faruk Aydın
30b75943f3 Merge pull request #1103 from automatisch/release/0.7.1
Release v0.7.1
2023-05-19 20:31:09 +02:00
Faruk AYDIN
41a67b402d Release v0.7.1 2023-05-19 19:56:51 +02:00
Faruk AYDIN
caa104b1cc chore: Update version to 0.7.1 in Dockerfiles 2023-05-19 19:56:01 +02:00
Rıdvan Akca
94085f2bc8 fix(shared-drive): add missing field to payload 2023-05-19 17:55:52 +02:00
Faruk AYDIN
d39c962314 docs: Add Google Sheets to available apps 2023-05-19 16:15:46 +02:00
Ömer Faruk Aydın
706fb0f063 Merge pull request #1100 from automatisch/release/0.7.0
Release v0.7.0
2023-05-19 13:16:42 +02:00
Faruk AYDIN
b9d89b040f Release v0.7.0 2023-05-19 12:29:29 +02:00
Faruk AYDIN
41421b849a chore: Update version to 0.7.0 in Dockerfiles 2023-05-19 12:28:59 +02:00
Rıdvan Akca
324375da93 fix(shared-drive): show shared drive items 2023-05-19 07:35:49 +02:00
Ali BARIN
536446faf6 fix(twilio/receive-sms): use phonenumber sid in removing webhook 2023-05-17 12:57:02 +02:00
Rıdvan Akca
d026ac09f3 feat(google-sheets): add new worksheets trigger 2023-05-16 23:42:23 +02:00
Ali BARIN
88c93ac992 fix(dynamic-data): correct parameters 2023-05-16 17:59:04 +02:00
Ali BARIN
d540322d8b refactor(webhook): respond with 204 instead of 200 2023-05-16 16:40:12 +02:00
Ali BARIN
ad4db5e936 feat(twilio/send-sms): use dynamic phone numbers 2023-05-16 16:40:12 +02:00
Ali BARIN
25cb4d90f3 refactor(twilio/receive-sms): convert to webhook 2023-05-16 16:40:12 +02:00
Ömer Faruk Aydın
6c14a353ef Merge pull request #1093 from automatisch/openai-chat-prompt
feat(openai): add chat prompt
2023-05-16 12:46:41 +02:00
Ali BARIN
74d7d1aa98 feat(openai): add chat prompt 2023-05-15 20:41:25 +00:00
Ali BARIN
43b0d9ed29 fix: refetch step executions upon deleting and testing steps 2023-05-15 16:24:26 +02:00
Ali BARIN
3572e6f65a fix: send parameters to dynamic data query 2023-05-15 16:24:17 +02:00
Ali BARIN
d23d5d2da0 feat(slack): send direct message 2023-05-15 16:24:17 +02:00
Faruk AYDIN
183b9b0d88 feat: Add enable ssl field to PostgreSQL connection 2023-05-15 16:23:43 +02:00
Faruk AYDIN
7a1af268ae chore: Remove empty line from PostgreSQL index file 2023-05-15 16:23:43 +02:00
Ali BARIN
f879b3c5b0 refactor(postgresql): rename pgClient with client 2023-05-15 16:23:43 +02:00
Ali BARIN
40be72cf65 feat(postgresql/delete): add interactive where clause entries 2023-05-15 16:23:43 +02:00
Ali BARIN
a8886571d1 feat(postgresql/update): add interactive where clause 2023-05-15 16:23:43 +02:00
Ali BARIN
1fcd51ea26 refactor(postgresql/insert): use withSchema 2023-05-15 16:23:43 +02:00
Ali BARIN
89752138be refactor(postgresql): use bindings to set run-time params 2023-05-15 16:23:43 +02:00
Ali BARIN
f29ccace2a chore(postgresql): rename app folder and add icon 2023-05-15 16:23:43 +02:00
Shehab Ghazy
0c8343e76f feat(postgresql): add auth and primitive actions 2023-05-15 16:23:43 +02:00
Ali BARIN
9776c9f5a4 feat: add duplicate flow functionality 2023-05-13 14:44:21 +02:00
Ali BARIN
a5dbac9817 feat(ExecutionStep): show execution date 2023-05-13 13:20:05 +02:00
Ali BARIN
bad5e0b855 fix(queries/get-execution): serve soft deleteds 2023-05-13 13:19:53 +02:00
Ali BARIN
8e4ca55560 feat(ControlledAutocomplete): filter by value too 2023-05-13 13:19:42 +02:00
Ali BARIN
f52afc1fe0 chore: serve graphql explorer only on development 2023-05-13 13:19:35 +02:00
Ali BARIN
815e64302e fix(Editor): don't unregister step parameters 2023-05-13 13:19:28 +02:00
Faruk AYDIN
07b2b18a4e fix: Run remove cancelled subscriptions only in the cloud 2023-05-12 13:30:06 +02:00
Ömer Faruk Aydın
69eca33de7 Merge pull request #1080 from automatisch/docs-postgres-ssl
docs: Add POSTGRES_ENABLE_SSL env variable to configuration
2023-05-11 14:50:32 +02:00
Faruk AYDIN
ec76a480d0 docs: Add POSTGRES_ENABLE_SSL env variable to configuration 2023-05-11 13:58:02 +02:00
Ali BARIN
a8823c3ed0 fix(filters/continue): cover multiple conditions 2023-05-09 18:28:51 +02:00
Ali BARIN
1f1b3a341c refactor: make sentry cloud agnostic 2023-05-09 13:17:33 +02:00
Ali BARIN
8c164a3852 fix(http-request): suppress failure upon size check 2023-05-08 14:45:41 +02:00
Ali BARIN
dcf526d810 feat(http-request): convert non-text data to base64 2023-05-08 12:52:09 +02:00
Ali BARIN
2fc6d680a0 fix: incorporate spaces in variables 2023-05-08 12:52:09 +02:00
Ali BARIN
f414972f33 Merge pull request #1074 from automatisch/executions-updated-at
feat: sort executions by updated at
2023-05-08 12:49:15 +02:00
Ali BARIN
69d192d989 feat: sort executions by updated at 2023-05-02 09:30:41 +00:00
Ömer Faruk Aydın
6c8769e598 Merge pull request #1071 from automatisch/paddle-plans
feat: Introduce new plans for the cloud
2023-04-26 13:21:18 +02:00
Faruk AYDIN
c12703422c feat: Introduce new plans for the cloud 2023-04-26 12:13:39 +02:00
Ömer Faruk Aydın
c0171e1cd1 Merge pull request #1070 from automatisch/dockerfile.cloud
chore: add dockerfile for cloud
2023-04-26 11:20:00 +02:00
Ali BARIN
920a983146 chore: add dockerfile for cloud 2023-04-25 20:43:48 +00:00
Ömer Faruk Aydın
7ec86bfef1 Merge pull request #1069 from automatisch/release/0.6.1
Release v0.6.1
2023-04-25 18:17:04 +02:00
Faruk AYDIN
d8bc318688 Release v0.6.1 2023-04-25 17:15:20 +02:00
Faruk AYDIN
600ea1848f chore: Update version to 0.6.1 in Dockerfiles 2023-04-25 17:14:43 +02:00
Ömer Faruk Aydın
a3ce9c7662 Merge pull request #1068 from automatisch/copy-hbs
fix: Include email templates for the build
2023-04-25 17:10:50 +02:00
Faruk AYDIN
44ce7577c6 fix: Include email templates for the build 2023-04-25 17:07:46 +02:00
Ömer Faruk Aydın
8c3e42f7eb Merge pull request #1067 from automatisch/company-name
chore: Use company name for the enterprise license
2023-04-24 18:20:34 +02:00
Faruk AYDIN
b8887c506c chore: Use company name for the enterprise license 2023-04-24 16:38:54 +02:00
Ömer Faruk Aydın
6c4228b7b8 Merge pull request #1063 from automatisch/docs/encryption-key-generation
docs: Explain how to create random keys for encryption and webhook secret keys
2023-04-20 17:07:38 +02:00
Faruk AYDIN
9b1da98386 docs: Explain how to create random keys for encryption and webhook secret keys 2023-04-19 18:44:19 +02:00
Ömer Faruk Aydın
1615169a3d Merge pull request #1060 from automatisch/release/0.6.0
Release v0.6.0
2023-04-17 19:05:25 +02:00
Faruk AYDIN
df83aa4d15 chore: Update version with 0.6.0 2023-04-17 18:37:24 +02:00
100 changed files with 1961 additions and 163 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
**/node_modules/
**/dist/
**/logs/
**/.devcontainer
**/.github
**/.vscode
packages/docs
packages/e2e-test

View File

@@ -1,5 +1,5 @@
The Automatisch Enterprise license (the “Enterprise License”)
Copyright (c) 2023 Ömer Faruk Aydın, Ali Barın.
Copyright (c) 2023-present AB Software GmbH.
With regard to the Automatisch Software:

View File

@@ -4,7 +4,7 @@ WORKDIR /automatisch
RUN \
apk --no-cache add --virtual build-dependencies python3 build-base && \
yarn global add @automatisch/cli@0.5.0 --network-timeout 1000000 && \
yarn global add @automatisch/cli@0.7.1 --network-timeout 1000000 && \
rm -rf /usr/local/share/.cache/ && \
apk del build-dependencies

19
docker/Dockerfile.cloud Normal file
View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM node:16-alpine
WORKDIR /automatisch
ENV PORT 3000
RUN ls -lna
# copy the app, note .dockerignore
COPY . ./
RUN yarn
RUN yarn lerna bootstrap
RUN yarn lerna run --scope=@*/{web,backend,cli} build
COPY ./docker/entrypoint-cloud.sh /entrypoint-cloud.sh
EXPOSE 3000
ENTRYPOINT ["sh", "/entrypoint-cloud.sh"]

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM automatischio/automatisch:0.5.0
FROM automatischio/automatisch:0.7.1
WORKDIR /automatisch
RUN apk add --no-cache openssl dos2unix

9
docker/entrypoint-cloud.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -e
if [ -n "$WORKER" ]; then
yarn automatisch start-worker
else
yarn automatisch start
fi

View File

@@ -2,7 +2,7 @@
"packages": [
"packages/*"
],
"version": "0.6.0",
"version": "0.7.1",
"npmClient": "yarn",
"useWorkspaces": true,
"command": {

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/backend",
"version": "0.6.0",
"version": "0.7.1",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"scripts": {
@@ -17,12 +17,12 @@
"db:migration:create": "knex migrate:make",
"db:rollback": "knex migrate:rollback",
"db:migrate": "knex migrate:latest",
"copy-statics": "copyfiles src/**/*.{graphql,json,svg} dist",
"copy-statics": "copyfiles src/**/*.{graphql,json,svg,hbs} dist",
"prepack": "yarn build",
"prebuild": "rm -rf ./dist"
},
"dependencies": {
"@automatisch/web": "^0.6.0",
"@automatisch/web": "^0.7.1",
"@bull-board/express": "^3.10.1",
"@graphql-tools/graphql-file-loader": "^7.3.4",
"@graphql-tools/load": "^7.5.2",
@@ -100,7 +100,7 @@
"url": "https://github.com/automatisch/automatisch/issues"
},
"devDependencies": {
"@automatisch/types": "^0.6.0",
"@automatisch/types": "^0.7.1",
"@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.8",
"@types/cors": "^2.8.12",

View File

@@ -10,7 +10,7 @@ type TGroupItem = {
type TGroup = Record<'and', TGroupItem[]>;
const isEqual = (a: string, b: string) => a === b;
const isNotEqual = (a: string, b: string) => !isEqual(a, b)
const isNotEqual = (a: string, b: string) => !isEqual(a, b);
const isGreaterThan = (a: string, b: string) => Number(a) > Number(b);
const isLessThan = (a: string, b: string) => Number(a) < Number(b);
const isGreaterThanOrEqual = (a: string, b: string) => Number(a) >= Number(b);
@@ -18,6 +18,36 @@ const isLessThanOrEqual = (a: string, b: string) => Number(a) <= Number(b);
const contains = (a: string, b: string) => a.includes(b);
const doesNotContain = (a: string, b: string) => !contains(a, b);
const shouldContinue = (orGroups: TGroup[]) => {
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;
}
type TOperatorFunc = (a: string, b: string) => boolean;
type TOperators = {
@@ -66,7 +96,7 @@ export default defineAction({
return groups;
}, []);
if (matchingGroups.length === 0) {
if (!shouldContinue(orGroups)) {
$.execution.exit();
}

View File

@@ -11,13 +11,20 @@ export default {
data: [],
};
const params = {
const params: Record<string, unknown> = {
q: `mimeType='application/vnd.google-apps.folder'`,
orderBy: 'createdTime desc',
pageToken: undefined as unknown as string,
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`,

View File

@@ -32,6 +32,7 @@ export default defineTrigger({
key: 'folderId',
type: 'dropdown' as const,
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,
@@ -43,6 +44,10 @@ export default defineTrigger({
name: 'key',
value: 'listFolders',
},
{
name: 'parameters.driveId',
value: '{parameters.driveId}',
},
],
},
},

View File

@@ -7,15 +7,21 @@ const newFilesInFolder = async ($: IGlobalVariable) => {
} else {
q += ` and parents in 'root'`;
}
const params = {
const params: Record<string, unknown> = {
pageToken: undefined as unknown as string,
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;

View File

@@ -1,15 +1,21 @@
import { IGlobalVariable } from '@automatisch/types';
const newFiles = async ($: IGlobalVariable) => {
const params = {
const params: Record<string, unknown> = {
pageToken: undefined as unknown as string,
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;

View File

@@ -32,6 +32,7 @@ export default defineTrigger({
key: 'folderId',
type: 'dropdown' as const,
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,
@@ -43,6 +44,10 @@ export default defineTrigger({
name: 'key',
value: 'listFolders',
},
{
name: 'parameters.driveId',
value: '{parameters.driveId}',
},
],
},
},

View File

@@ -8,15 +8,21 @@ const newFolders = async ($: IGlobalVariable) => {
q += ` and parents in 'root'`;
}
const params = {
const params: Record<string, unknown> = {
pageToken: undefined as unknown as string,
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;

View File

@@ -32,6 +32,7 @@ export default defineTrigger({
key: 'folderId',
type: 'dropdown' as const,
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: {
@@ -42,6 +43,10 @@ export default defineTrigger({
name: 'key',
value: 'listFolders',
},
{
name: 'parameters.driveId',
value: '{parameters.driveId}',
},
],
},
},

View File

@@ -12,15 +12,21 @@ const updatedFiles = async ($: IGlobalVariable) => {
q += ` and parents in 'root'`;
}
const params = {
const params: Record<string, unknown> = {
pageToken: undefined as unknown as string,
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;

View File

@@ -1,3 +1,4 @@
import listDrives from './list-drives';
import listSpreadsheets from './list-spreadsheets';
export default [listDrives];
export default [listDrives, listSpreadsheets];

View File

@@ -0,0 +1,47 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List spreadsheets',
key: 'listSpreadsheets',
async run($: IGlobalVariable) {
const spreadsheets: {
data: IJSONObject[];
} = {
data: [],
};
const params: Record<string, unknown> = {
q: `mimeType='application/vnd.google-apps.spreadsheet'`,
pageSize: 100,
pageToken: undefined as unknown as string,
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;
},
};

View File

@@ -1,3 +1,4 @@
import newSpreadsheets from './new-spreadsheets';
import newWorksheets from './new-worksheets';
export default [newSpreadsheets];
export default [newSpreadsheets, newWorksheets];

View File

@@ -1,15 +1,21 @@
import { IGlobalVariable } from '@automatisch/types';
const newSpreadsheets = async ($: IGlobalVariable) => {
const params = {
const params: Record<string, unknown> = {
pageToken: undefined as unknown as string,
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',

View File

@@ -0,0 +1,57 @@
import defineTrigger from '../../../../helpers/define-trigger';
import newWorksheets from './new-worksheets';
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' as const,
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' as const,
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($);
},
});

View File

@@ -0,0 +1,28 @@
import { IGlobalVariable } from '@automatisch/types';
const newWorksheets = async ($: IGlobalVariable) => {
const params = {
pageToken: undefined as unknown as string,
};
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;

View File

@@ -1,3 +1,4 @@
import type { AxiosRequestConfig } from 'axios';
import defineAction from '../../../../helpers/define-action';
type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -9,6 +10,23 @@ type THeaderEntry = {
type THeaderEntries = THeaderEntry[];
function isPossiblyTextBased(contentType: string) {
if (!contentType) return false;
return contentType.startsWith('application/json')
|| contentType.startsWith('text/');
}
function throwIfFileSizeExceedsLimit(contentLength: string) {
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',
@@ -81,29 +99,51 @@ export default defineAction({
const data = $.step.parameters.data as string;
const url = $.step.parameters.url as string;
const headers = $.step.parameters.headers as THeaderEntries;
const maxFileSize = 25 * 1024 * 1024; // 25MB
const headersObject = headers.reduce((result, entry) => ({ ...result, [entry.key]: entry.value }), {})
const headersObject: Record<string, string> = headers.reduce((result, entry) => {
const key = entry.key?.toLowerCase();
const value = entry.value;
const metadataResponse = await $.http.head(url, { headers: headersObject });
if (key && value) {
return {
...result,
[entry.key?.toLowerCase()]: entry.value
}
}
if (Number(metadataResponse.headers['content-length']) > maxFileSize) {
throw new Error(
`Response is too large. Maximum size is 25MB. Actual size is ${metadataResponse.headers['content-length']}`
);
}
return result;
}, {});
const response = await $.http.request({
let contentType = headersObject['content-type'];
// in case HEAD request is not supported by the URL
try {
const metadataResponse = await $.http.head(url, { headers: headersObject });
contentType = metadataResponse.headers['content-type'];
throwIfFileSizeExceedsLimit(metadataResponse.headers['content-length']);
// eslint-disable-next-line no-empty
} catch { }
const requestData: AxiosRequestConfig = {
url,
method,
data,
headers: headersObject,
});
};
if (!isPossiblyTextBased(contentType)) {
requestData.responseType = 'arraybuffer';
}
const response = await $.http.request(requestData);
throwIfFileSizeExceedsLimit(response.headers['content-length']);
let responseData = response.data;
if (typeof response.data === 'string') {
responseData = response.data.replaceAll('\u0000', '');
if (!isPossiblyTextBased(contentType)) {
responseData = Buffer.from(responseData as string).toString('base64');
}
$.setActionItem({ raw: { data: responseData } });

View File

@@ -1,4 +1,5 @@
import checkModeration from './check-moderation';
import sendPrompt from './send-prompt';
import sendChatPrompt from './send-chat-prompt';
export default [checkModeration, sendPrompt];
export default [checkModeration, sendChatPrompt, sendPrompt];

View File

@@ -0,0 +1,137 @@
import defineAction from '../../../../helpers/define-action';
type TMessage = {
role: string;
content: string;
}
const castFloatOrUndefined = (value: string | null) => {
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' as const,
required: true,
variables: false,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listModels',
},
],
},
},
{
label: 'Messages',
key: 'messages',
type: 'dynamic' as const,
required: false,
description: 'Add or remove messages as needed',
value: [{ role: 'system', body: '' }],
fields: [
{
label: 'Role',
key: 'role',
type: 'dropdown' as const,
required: true,
options: [
{
label: 'System',
value: 'system',
},
{
label: 'User',
value: 'user',
}
],
},
{
label: 'Content',
key: 'content',
type: 'string' as const,
required: true,
variables: true,
}
],
},
{
label: 'Temperature',
key: 'temperature',
type: 'string' as const,
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' as const,
required: false,
variables: true,
description: 'The maximum number of tokens to generate in the completion.'
},
{
label: 'Stop Sequence',
key: 'stopSequence',
type: 'string' as const,
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' as const,
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' as const,
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: 'presencePenalty',
key: 'presencePenalty',
type: 'string' as const,
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 as string,
temperature: castFloatOrUndefined($.step.parameters.temperature as string),
max_tokens: castFloatOrUndefined($.step.parameters.maxTokens as string),
stop: ($.step.parameters.stopSequence as string || null),
top_p: castFloatOrUndefined($.step.parameters.topP as string),
frequency_penalty: castFloatOrUndefined($.step.parameters.frequencyPenalty as string),
presence_penalty: castFloatOrUndefined($.step.parameters.presencePenalty as string),
messages: ($.step.parameters.messages as TMessage[]).map(message => ({
role: message.role,
content: message.content,
})),
};
const { data } = await $.http.post('/v1/chat/completions', payload);
$.setActionItem({
raw: data,
});
},
});

View File

@@ -0,0 +1,111 @@
import { IJSONArray } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
import getClient from '../../common/postgres-client';
import setParams from '../../common/set-run-time-parameters';
import whereClauseOperators from '../../common/where-clause-operators';
type TWhereClauseEntry = { columnName: string, value: string, operator: string };
type TWhereClauseEntries = TWhereClauseEntry[];
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' as const,
value: 'public',
required: true,
variables: false,
},
{
label: 'Table name',
key: 'table',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Where clause entries',
key: 'whereClauseEntries',
type: 'dynamic' as const,
required: true,
fields: [
{
label: 'Column name',
key: 'columnName',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Operator',
key: 'operator',
type: 'dropdown' as const,
required: true,
variables: false,
options: whereClauseOperators
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
]
},
{
label: 'Run-time parameters',
key: 'params',
type: 'dynamic' as const,
required: false,
description: 'Change run-time configuration parameters with SET command',
fields: [
{
label: 'Parameter name',
key: 'parameter',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
],
}
],
async run($) {
const client = getClient($);
await setParams(client, $.step.parameters.params);
const whereClauseEntries = $.step.parameters.whereClauseEntries as TWhereClauseEntries;
const response = await client($.step.parameters.table as string)
.withSchema($.step.parameters.schema as string)
.returning('*')
.where((builder) => {
for (const whereClauseEntry of whereClauseEntries) {
const { columnName, operator, value } = whereClauseEntry;
if (columnName) {
builder.where(columnName, operator, value);
}
}
})
.del() as IJSONArray;
$.setActionItem({
raw: {
rows: response
}
});
},
});

View File

@@ -0,0 +1,6 @@
import insertAction from './insert';
import updateAction from './update';
import deleteAction from './delete';
import SQLQuery from './sql-query'
export default [insertAction, updateAction, deleteAction, SQLQuery];

View File

@@ -0,0 +1,93 @@
import { IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
import getClient from '../../common/postgres-client';
import setParams from '../../common/set-run-time-parameters';
type TColumnValueEntries = { columnName: string, value: string }[];
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' as const,
value: 'public',
required: true,
variables: false,
},
{
label: 'Table name',
key: 'table',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Column - value entries',
key: 'columnValueEntries',
type: 'dynamic' as const,
required: true,
description: 'Table columns with values',
fields: [
{
label: 'Column name',
key: 'columnName',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
],
},
{
label: 'Run-time parameters',
key: 'params',
type: 'dynamic' as const,
required: false,
description: 'Change run-time configuration parameters with SET command',
fields: [
{
label: 'Parameter name',
key: 'parameter',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
],
}
],
async run($) {
const client = getClient($);
await setParams(client, $.step.parameters.params);
const fields = $.step.parameters.columnValueEntries as TColumnValueEntries;
const data = fields.reduce((result, { columnName, value }) => ({
...result,
[columnName]: value,
}), {});
const response = await client($.step.parameters.table as string)
.withSchema($.step.parameters.schema as string)
.returning('*')
.insert(data) as IJSONObject;
$.setActionItem({ raw: response[0] as IJSONObject });
},
});

View File

@@ -0,0 +1,56 @@
import defineAction from '../../../../helpers/define-action';
import getClient from '../../common/postgres-client';
import setParams from '../../common/set-run-time-parameters';
export default defineAction({
name: 'SQL query',
key: 'SQLQuery',
description: 'Executes the given SQL statement.',
arguments: [
{
label: 'SQL statement',
key: 'queryStatement',
type: 'string' as const,
value: 'public',
required: true,
variables: true,
},
{
label: 'Run-time parameters',
key: 'params',
type: 'dynamic' as const,
required: false,
description: 'Change run-time configuration parameters with SET command',
fields: [
{
label: 'Parameter name',
key: 'parameter',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
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);
$.setActionItem({
raw: {
rows
}
});
},
});

View File

@@ -0,0 +1,141 @@
import { IJSONArray } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
import getClient from '../../common/postgres-client';
import setParams from '../../common/set-run-time-parameters';
import whereClauseOperators from '../../common/where-clause-operators';
type TColumnValueEntries = { columnName: string, value: string }[];
type TWhereClauseEntry = { columnName: string, value: string, operator: string };
type TWhereClauseEntries = TWhereClauseEntry[];
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' as const,
value: 'public',
required: true,
variables: false,
},
{
label: 'Table name',
key: 'table',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Where clause entries',
key: 'whereClauseEntries',
type: 'dynamic' as const,
required: true,
fields: [
{
label: 'Column name',
key: 'columnName',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Operator',
key: 'operator',
type: 'dropdown' as const,
required: true,
variables: false,
options: whereClauseOperators
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
]
},
{
label: 'Column - value entries',
key: 'columnValueEntries',
type: 'dynamic' as const,
required: true,
description: 'Table columns with values',
fields: [
{
label: 'Column name',
key: 'columnName',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
],
},
{
label: 'Run-time parameters',
key: 'params',
type: 'dynamic' as const,
required: false,
description: 'Change run-time configuration parameters with SET command',
fields: [
{
label: 'Parameter name',
key: 'parameter',
type: 'string' as const,
required: true,
variables: false,
},
{
label: 'Value',
key: 'value',
type: 'string' as const,
required: true,
variables: true,
}
],
}
],
async run($) {
const client = getClient($);
await setParams(client, $.step.parameters.params);
const whereClauseEntries = $.step.parameters.whereClauseEntries as TWhereClauseEntries;
const fields = $.step.parameters.columnValueEntries as TColumnValueEntries;
const data: Record<string, unknown> = fields.reduce((result, { columnName, value }) => ({
...result,
[columnName]: value,
}), {});
const response = await client($.step.parameters.table as string)
.withSchema($.step.parameters.schema as string)
.returning('*')
.where((builder) => {
for (const whereClauseEntry of whereClauseEntries) {
const { columnName, operator, value } = whereClauseEntry;
if (columnName) {
builder.where(columnName, operator, value);
}
}
})
.update(data) as IJSONArray;
$.setActionItem({
raw: {
rows: response
}
});
},
});

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,98 @@
import verifyCredentials from './verify-credentials';
import isStillVerified from './is-still-verified';
export default {
fields: [
{
key: 'version',
label: 'PostgreSQL version',
type: 'string' as const,
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' as const,
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' as const,
required: true,
readOnly: false,
value: '5432',
placeholder: null,
description: 'The port of the PostgreSQL database.',
clickToCopy: false,
},
{
key: 'enableSsl',
label: 'Enable SSL',
type: 'dropdown' as const,
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' as const,
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' as const,
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' as const,
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'The password of the PostgreSQL database user.',
clickToCopy: false,
},
],
verifyCredentials,
isStillVerified,
};

View File

@@ -0,0 +1,10 @@
import { IGlobalVariable } from '@automatisch/types';
import verifyCredentials from './verify-credentials';
const isStillVerified = async ($: IGlobalVariable) => {
await verifyCredentials($);
return true;
};
export default isStillVerified;

View File

@@ -0,0 +1,25 @@
import { IGlobalVariable } from '@automatisch/types';
import logger from '../../../helpers/logger';
import getClient from '../common/postgres-client';
const verifyCredentials = async ($: IGlobalVariable) => {
const client = getClient($);
const checkConnection = await client.raw('SELECT 1');
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;

View File

@@ -0,0 +1,22 @@
import knex, { Knex } from 'knex';
import { IGlobalVariable } from '@automatisch/types';
const getClient = ($: IGlobalVariable): Knex<any, unknown[]> => {
const client = knex({
client: 'pg',
version: $.auth.data.version as string,
connection: {
host: $.auth.data.host as string,
port: Number($.auth.data.port),
ssl: ($.auth.data.enableSsl === 'true' ||
$.auth.data.enableSsl === true) as boolean,
user: $.auth.data.user as string,
password: $.auth.data.password as string,
database: $.auth.data.database as string,
},
});
return client;
};
export default getClient;

View File

@@ -0,0 +1,19 @@
import { Knex } from 'knex';
import { type IJSONValue } from '@automatisch/types';
type TParams = { parameter: string; value: string; }[];
const setParams = async (client: Knex<any, unknown[]>, params: IJSONValue = []): Promise<void> => {
for (const { parameter, value } of (params as TParams)) {
if (parameter) {
const bindings = {
parameter,
value,
};
await client.raw('SET :parameter: = :value:', bindings);
}
}
};
export default setParams;

View File

@@ -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;

View File

View File

@@ -0,0 +1,16 @@
import defineApp from '../../helpers/define-app';
import auth from './auth';
import actions from './actions';
export default defineApp({
name: 'PostgreSQL',
key: 'postgresql',
iconUrl: '{BASE_URL}/apps/postgresql/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/postgresql/connection',
supportsConnections: true,
baseUrl: '',
apiBaseUrl: '',
primaryColor: '336791',
auth,
actions,
});

View File

@@ -1,5 +1,6 @@
import findMessage from './find-message';
import findUserByEmail from './find-user-by-email';
import sendMessageToChannel from './send-a-message-to-channel';
import sendDirectMessage from './send-a-direct-message';
export default [findMessage, findUserByEmail, sendMessageToChannel];
export default [findMessage, findUserByEmail, sendMessageToChannel, sendDirectMessage];

View File

@@ -0,0 +1,76 @@
import defineAction from '../../../../helpers/define-action';
import postMessage from './post-message';
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' as const,
required: true,
description: 'Pick a user to send the message to.',
variables: false,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listUsers',
},
],
},
},
{
label: 'Message text',
key: 'message',
type: 'string' as const,
required: true,
description: 'The content of your new message.',
variables: true,
},
{
label: 'Send as a bot?',
key: 'sendAsBot',
type: 'dropdown' as const,
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: false,
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;
},
});

View File

@@ -0,0 +1,55 @@
import { IGlobalVariable } from '@automatisch/types';
import { URL } from 'url';
type TData = {
channel: string;
text: string;
username?: string;
icon_url?: string;
icon_emoji?: string;
};
const postMessage = async ($: IGlobalVariable) => {
const { parameters } = $.step;
const toUsername = parameters.toUsername as string;
const text = parameters.message as string;
const sendAsBot = parameters.sendAsBot as boolean;
const botName = parameters.botName as string;
const botIcon = parameters.botIcon as string;
const data: TData = {
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;

View File

@@ -1,3 +1,4 @@
import listChannels from './list-channels';
import listUsers from './list-users';
export default [listChannels];
export default [listChannels, listUsers];

View File

@@ -36,7 +36,7 @@ export default {
do {
const response: TResponse = await $.http.get('/conversations.list', {
params: {
types: 'public_channel,private_channel,im',
types: 'public_channel,private_channel',
cursor: nextCursor,
limit: 1000,
}

View File

@@ -0,0 +1,66 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
type TMember = {
id: string;
profile: {
real_name_normalized: string;
};
}
type TUserListResponseData = {
members: TMember[],
response_metadata?: {
next_cursor: string
};
needed?: string;
error?: string;
ok: boolean;
}
type TResponse = {
data: TUserListResponseData;
}
export default {
name: 'List users',
key: 'listUsers',
async run($: IGlobalVariable) {
const users: {
data: IJSONObject[];
error: IJSONObject | null;
} = {
data: [],
error: null,
};
let nextCursor;
do {
const response: TResponse = 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 as string,
name: member.profile.real_name_normalized as string,
});
}
} while (nextCursor);
return users;
},
};

View File

@@ -1,3 +1,4 @@
import { URLSearchParams } from 'node:url';
import defineAction from '../../../../helpers/define-action';
export default defineAction({
@@ -8,11 +9,21 @@ export default defineAction({
{
label: 'From Number',
key: 'fromNumber',
type: 'string' as const,
type: 'dropdown' as const,
required: true,
description:
'The number to send the SMS from. Include country code. Example: 15551234567',
variables: true,
variables: false,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listIncomingPhoneNumbers',
},
],
},
},
{
label: 'To Number',
@@ -35,14 +46,20 @@ export default defineAction({
async run($) {
const requestPath = `/2010-04-01/Accounts/${$.auth.data.accountSid}/Messages.json`;
const messageBody = $.step.parameters.message;
const messageBody = $.step.parameters.message as string;
const fromNumber = '+' + ($.step.parameters.fromNumber as string).trim();
const toNumber = '+' + ($.step.parameters.toNumber as string).trim();
const fromNumber = ($.step.parameters.fromNumber as string).trim();
const toNumber = ($.step.parameters.toNumber as string).trim();
const payload = new URLSearchParams({
Body: messageBody,
From: fromNumber,
To: toNumber,
}).toString();
const response = await $.http.post(
requestPath,
`Body=${messageBody}&From=${fromNumber}&To=${toNumber}`
payload,
);
$.setActionItem({ raw: response.data });

View File

@@ -0,0 +1,3 @@
import listIncomingPhoneNumbers from './list-incoming-phone-numbers';
export default [listIncomingPhoneNumbers];

View File

@@ -0,0 +1,56 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
type TResponse = {
data: IJSONObject[];
error?: IJSONObject;
};
type TIncomingPhoneNumber = {
phone_number: string;
friendly_name: string;
sid: string;
capabilities: {
sms: boolean;
};
};
type TResponseData = {
incoming_phone_numbers: TIncomingPhoneNumber[];
next_page_uri: string;
};
export default {
name: 'List incoming phone numbers',
key: 'listIncomingPhoneNumbers',
async run($: IGlobalVariable) {
const valueType = $.step.parameters.valueType as string;
const isSid = valueType === 'sid';
const aggregatedResponse: TResponse = { data: [] };
let pathname = `/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers.json`;
do {
const response = await $.http.get<TResponseData>(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;
},
};

View File

@@ -3,6 +3,7 @@ import addAuthHeader from './common/add-auth-header';
import auth from './auth';
import triggers from './triggers';
import actions from './actions';
import dynamicData from './dynamic-data';
export default defineApp({
name: 'Twilio',
@@ -17,4 +18,5 @@ export default defineApp({
auth,
triggers,
actions,
dynamicData,
});

View File

@@ -1,23 +1,73 @@
import { URLSearchParams } from 'node:url';
import isEmpty from 'lodash/isEmpty';
import defineTrigger from '../../../../helpers/define-trigger';
import fetchMessages from './fetch-messages';
export default defineTrigger({
name: 'Receive SMS',
key: 'receiveSms',
pollInterval: 15,
type: 'webhook',
description: 'Triggers when a new SMS is received.',
arguments: [
{
label: 'To Number',
key: 'toNumber',
type: 'string',
key: 'phoneNumberSid',
type: 'dropdown' as const,
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($) {
async testRun($) {
await fetchMessages($);
if (!isEmpty($.lastExecutionStep?.dataOut)) {
$.pushTriggerItem({
raw: $.lastExecutionStep.dataOut,
meta: {
internalId: '',
}
});
}
},
async registerHook($) {
const phoneNumberSid = $.step.parameters.phoneNumberSid as string;
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 as string;
const payload = new URLSearchParams({
SmsUrl: '',
}).toString();
await $.http.post(
`/2010-04-01/Accounts/${$.auth.data.accountSid}/IncomingPhoneNumbers/${phoneNumberSid}.json`,
payload
);
},
});

View File

@@ -9,7 +9,9 @@ type AppConfig = {
webAppUrl: string;
webhookUrl: string;
appEnv: string;
logLevel: string;
isDev: boolean;
isProd: boolean;
postgresDatabase: string;
postgresSchema: string;
postgresPort: number;
@@ -79,7 +81,9 @@ const appConfig: AppConfig = {
protocol,
port,
appEnv: appEnv,
logLevel: process.env.LOG_LEVEL || 'info',
isDev: appEnv === 'development',
isProd: appEnv === 'production',
version: process.env.npm_package_version,
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',

View File

@@ -1,7 +1,8 @@
import Crypto from 'node:crypto';
import { Response } from 'express';
import bcrypt from 'bcrypt';
import { IRequest, ITriggerItem } from '@automatisch/types';
import logger from '../../helpers/logger';
import Flow from '../../models/flow';
import { processTrigger } from '../../services/trigger';
import actionQueue from '../../queues/action';
@@ -13,8 +14,19 @@ import {
} from '../../helpers/remove-job-configuration';
export default async (request: IRequest, response: Response) => {
const flowId = request.params.flowId;
// in case it's our built-in generic webhook trigger
let computedRequestPayload = {
headers: request.headers,
body: request.body,
query: request.query,
};
logger.debug(`Handling incoming webhook request at ${request.originalUrl}.`);
logger.debug(computedRequestPayload);
const flow = await Flow.query()
.findById(request.params.flowId)
.findById(flowId)
.throwIfNotFound();
const user = await flow.$relatedQuery('user');
@@ -56,36 +68,26 @@ export default async (request: IRequest, response: Response) => {
}
// in case trigger type is 'webhook'
let payload = request.body;
let rawInternalId: string | Buffer = request.rawBody;
// in case it's our built-in generic webhook trigger
if (isWebhookApp) {
payload = {
headers: request.headers,
body: request.body,
query: request.query,
};
rawInternalId = JSON.stringify(payload);
if (!isWebhookApp) {
computedRequestPayload = request.body;
}
const triggerItem: ITriggerItem = {
raw: payload,
raw: computedRequestPayload,
meta: {
internalId: await bcrypt.hash(rawInternalId, 1),
internalId: Crypto.randomUUID(),
},
};
const { flowId, executionId } = await processTrigger({
flowId: flow.id,
const { executionId } = await processTrigger({
flowId,
stepId: triggerStep.id,
triggerItem,
testRun,
});
if (testRun) {
return response.sendStatus(200);
return response.sendStatus(204);
}
const nextStep = await triggerStep.getNextStep();
@@ -104,5 +106,5 @@ export default async (request: IRequest, response: Response) => {
await actionQueue.add(jobName, jobPayload, jobOptions);
return response.sendStatus(200);
return response.sendStatus(204);
};

View File

@@ -9,7 +9,7 @@ export default class BaseError extends Error {
try {
computedError = JSON.parse(error as string);
} catch {
computedError = typeof error === 'string' ? { error } : error;
computedError = (typeof error === 'string' || Array.isArray(error)) ? { error } : error;
}
let computedMessage: string;

View File

@@ -9,6 +9,7 @@ import updateFlow from './mutations/update-flow';
import updateFlowStatus from './mutations/update-flow-status';
import executeFlow from './mutations/execute-flow';
import deleteFlow from './mutations/delete-flow';
import duplicateFlow from './mutations/duplicate-flow';
import createStep from './mutations/create-step';
import updateStep from './mutations/update-step';
import deleteStep from './mutations/delete-step';
@@ -31,6 +32,7 @@ const mutationResolvers = {
updateFlowStatus,
executeFlow,
deleteFlow,
duplicateFlow,
createStep,
updateStep,
deleteStep,

View File

@@ -0,0 +1,88 @@
import Context from '../../types/express/context';
import Step from '../../models/step';
type Params = {
input: {
id: string;
};
};
type NewStepIds = Record<string, string>;
function updateStepId(value: string, newStepIds: 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.replace(partialOldVariable, partialNewVariable);
}
return newValue;
}
function updateStepVariables(parameters: Step['parameters'], newStepIds: NewStepIds): Step['parameters'] {
const entries = Object.entries(parameters);
return entries.reduce((result, [key, value]: [string, unknown]) => {
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 duplicateFlow = async (
_parent: unknown,
params: Params,
context: Context
) => {
const flow = await context.currentUser
.$relatedQuery('flows')
.withGraphJoined('[steps]')
.orderBy('steps.position', 'asc')
.findOne({ 'flows.id': params.input.id })
.throwIfNotFound();
const duplicatedFlow = await context.currentUser
.$relatedQuery('flows')
.insert({
name: `Copy of ${flow.name}`,
active: false,
});
const newStepIds: NewStepIds = {};
for (const step of flow.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),
});
newStepIds[step.id] = duplicatedStep.id;
}
return duplicatedFlow;
};
export default duplicateFlow;

View File

@@ -16,6 +16,7 @@ const getExecution = async (
steps: true,
},
})
.withSoftDeleted()
.findById(params.executionId)
.throwIfNotFound();

View File

@@ -32,7 +32,7 @@ const getExecutions = async (
},
})
.groupBy('executions.id')
.orderBy('created_at', 'desc');
.orderBy('updated_at', 'desc');
return paginate(executions, params.limit, params.offset);
};

View File

@@ -56,6 +56,7 @@ type Mutation {
updateFlowStatus(input: UpdateFlowStatusInput): Flow
executeFlow(input: ExecuteFlowInput): executeFlowType
deleteFlow(input: DeleteFlowInput): Boolean
duplicateFlow(input: DuplicateFlowInput): Flow
createStep(input: CreateStepInput): Step
updateStep(input: UpdateStepInput): Step
deleteStep(input: DeleteStepInput): Step
@@ -324,6 +325,10 @@ input DeleteFlowInput {
id: String!
}
input DuplicateFlowInput {
id: String!
}
input CreateStepInput {
id: String
previousStepId: String

View File

@@ -1,13 +1,27 @@
const plans = [
import appConfig from '../../config/app';
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: string) {
return plans.find((plan) => plan.productId === id);
}

View File

@@ -2,7 +2,8 @@ import Step from '../models/step';
import ExecutionStep from '../models/execution-step';
import get from 'lodash.get';
const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[\da-zA-Z-_]+)+}})/g;
// INFO: don't remove space in allowed character group!
const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[\da-zA-Z-_ ]+)+}})/g;
export default function computeParameters(
parameters: Step['parameters'],

View File

@@ -11,24 +11,32 @@ import appConfig from '../config/app';
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: ExpressAdapter) => {
if (
!appConfig.enableBullMQDashboard ||
!appConfig.bullMQDashboardUsername ||
!appConfig.bullMQDashboardPassword
)
return;
if (!shouldEnableBullDashboard) return;
createBullBoard({
queues: [
new BullMQAdapter(flowQueue),
new BullMQAdapter(triggerQueue),
new BullMQAdapter(actionQueue),
new BullMQAdapter(emailQueue),
new BullMQAdapter(deleteUserQueue),
new BullMQAdapter(removeCancelledSubscriptionsQueue),
],
serverAdapter: serverAdapter,
queues,
serverAdapter,
});
};

View File

@@ -1,10 +1,11 @@
import { join } from 'path';
import { join } from 'node:path';
import { graphqlHTTP } from 'express-graphql';
import { loadSchemaSync } from '@graphql-tools/load';
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
import { addResolversToSchema } from '@graphql-tools/schema';
import { applyMiddleware } from 'graphql-middleware';
import appConfig from '../config/app';
import logger from '../helpers/logger';
import authentication from '../helpers/authentication';
import * as Sentry from '../helpers/sentry.ee';
@@ -22,7 +23,7 @@ const schemaWithResolvers = addResolversToSchema({
const graphQLInstance = graphqlHTTP({
schema: applyMiddleware(schemaWithResolvers, authentication),
graphiql: true,
graphiql: appConfig.isDev,
customFormatErrorFn: (error) => {
logger.error(error.path + ' : ' + error.message + '\n' + error.stack);

View File

@@ -9,10 +9,6 @@ const levels = {
debug: 4,
};
const level = () => {
return appConfig.appEnv === 'development' ? 'debug' : 'info';
};
const colors = {
error: 'red',
warn: 'yellow',
@@ -41,7 +37,7 @@ const transports = [
];
export const logger = winston.createLogger({
level: level(),
level: appConfig.logLevel,
levels,
format,
transports,

View File

@@ -5,8 +5,10 @@ import * as Tracing from '@sentry/tracing';
import appConfig from '../config/app';
const isSentryEnabled = !!appConfig.sentryDsn;
export function init(app?: Express) {
if (!appConfig.isCloud) return;
if (!isSentryEnabled) return;
return Sentry.init({
enabled: !!appConfig.sentryDsn,
@@ -22,19 +24,19 @@ export function init(app?: Express) {
export function attachRequestHandler(app: Express) {
if (!appConfig.isCloud) return;
if (!isSentryEnabled) return;
app.use(Sentry.Handlers.requestHandler());
}
export function attachTracingHandler(app: Express) {
if (!appConfig.isCloud) return;
if (!isSentryEnabled) return;
app.use(Sentry.Handlers.tracingHandler());
}
export function attachErrorHandler(app: Express) {
if (!appConfig.isCloud) return;
if (!isSentryEnabled) return;
app.use(Sentry.Handlers.errorHandler({
shouldHandleError() {
@@ -45,7 +47,7 @@ export function attachErrorHandler(app: Express) {
}
export function captureException(exception: any, captureContext?: CaptureContext) {
if (!appConfig.isCloud) return;
if (!isSentryEnabled) return;
return Sentry.captureException(exception, captureContext);
}

View File

@@ -4,6 +4,7 @@ import Execution from '../models/execution';
import ExecutionStep from '../models/execution-step';
import computeParameters from '../helpers/compute-parameters';
import globalVariable from '../helpers/global-variable';
import { logger } from '../helpers/logger';
import HttpError from '../errors/http';
import EarlyExitError from '../errors/early-exit';
import AlreadyProcessedError from '../errors/already-processed';
@@ -53,6 +54,8 @@ export const processAction = async (options: ProcessActionOptions) => {
const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess;
if (!shouldNotConsiderAsError) {
logger.error(error);
if (error instanceof HttpError) {
$.actionOutput.error = error.details;
} else {

View File

@@ -3,6 +3,7 @@ import globalVariable from '../helpers/global-variable';
import EarlyExitError from '../errors/early-exit';
import AlreadyProcessedError from '../errors/already-processed';
import HttpError from '../errors/http';
import { logger } from '../helpers/logger';
type ProcessFlowOptions = {
flowId: string;
@@ -35,6 +36,8 @@ export const processFlow = async (options: ProcessFlowOptions) => {
const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess;
if (!shouldNotConsiderAsError) {
logger.error(error);
if (error instanceof HttpError) {
$.triggerOutput.error = error.details;
} else {

View File

@@ -1,4 +1,5 @@
import * as Sentry from './helpers/sentry.ee';
import appConfig from './config/app';
Sentry.init();
@@ -9,8 +10,12 @@ import './workers/trigger';
import './workers/action';
import './workers/email';
import './workers/delete-user.ee';
import './workers/remove-cancelled-subscriptions.ee';
import './queues/remove-cancelled-subscriptions.ee';
if (appConfig.isCloud) {
import('./workers/remove-cancelled-subscriptions.ee');
import('./queues/remove-cancelled-subscriptions.ee');
}
import telemetry from './helpers/telemetry';
telemetry.setServiceType('worker');

View File

@@ -26,6 +26,8 @@ export const worker = new Worker(
const { stepId, flowId, executionId, computedParameters, executionStep } =
await processAction(job.data as JobData);
if (executionStep.isFailed) return;
const step = await Step.query().findById(stepId).throwIfNotFound();
const nextStep = await step.getNextStep();

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/cli",
"version": "0.6.0",
"version": "0.7.1",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"contributors": [
@@ -33,7 +33,7 @@
"version": "oclif readme && git add README.md"
},
"dependencies": {
"@automatisch/backend": "^0.6.0",
"@automatisch/backend": "^0.7.1",
"@oclif/core": "^1",
"@oclif/plugin-help": "^5",
"@oclif/plugin-plugins": "^2.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/docs",
"version": "0.6.0",
"version": "0.7.1",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"private": true,

View File

@@ -68,6 +68,15 @@ export default defineConfig({
{ 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,

View File

@@ -14,29 +14,31 @@ The default values for some environment variables might be different in our deve
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 |
| `POSTGRES_DATABASE` | string | `automatisch` | Database Name |
| `POSTGRES_SCHEMA` | string | `public` | Database Schema |
| `POSTGRES_PORT` | number | `5432` | Database Port |
| `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_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 |
| 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_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 |

View File

@@ -0,0 +1,12 @@
---
favicon: /favicons/filter.svg
items:
- name: Continue if conditions match
desc: Let the execution continue if the conditions match.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -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

View File

@@ -3,6 +3,8 @@ 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
---
<script setup>

View File

@@ -7,6 +7,8 @@ items:
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.
---
<script setup>

View File

@@ -10,10 +10,12 @@ Following integrations are currently supported by Automatisch.
- [Delay](/apps/delay/actions)
- [Discord](/apps/discord/actions)
- [Dropbox](/apps/dropbox/actions)
- [Filter](/apps/filter/actions)
- [Flickr](/apps/flickr/triggers)
- [Github](/apps/github/triggers)
- [Google Drive](/apps/google-drive/triggers)
- [Google Forms](/apps/google-forms/triggers)
- [Google Sheets](/apps/google-sheets/triggers)
- [HTTP Request](/apps/http-request/actions)
- [Ntfy](/apps/ntfy/actions)
- [OpenAI](/apps/openai/actions)

View File

@@ -74,6 +74,10 @@ 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
<a href="https://render.com/deploy?repo=https://github.com/automatisch/automatisch">

View File

@@ -0,0 +1,8 @@
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Shape" fill="#000000" transform="translate(42.666667, 85.333333)">
<path d="M3.55271368e-14,1.42108547e-14 L191.565013,234.666667 L192,234.666667 L192,384 L234.666667,384 L234.666667,234.666667 L426.666667,1.42108547e-14 L3.55271368e-14,1.42108547e-14 Z M214.448,192 L211.81248,192 L89.9076267,42.6666667 L336.630187,42.6666667 L214.448,192 Z">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/e2e-tests",
"version": "0.6.0",
"version": "0.7.1",
"license": "See LICENSE file",
"private": true,
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/types",
"version": "0.6.0",
"version": "0.7.1",
"license": "See LICENSE file",
"description": "Type definitions for automatisch",
"homepage": "https://github.com/automatisch/automatisch",

View File

@@ -1,17 +1,18 @@
{
"name": "@automatisch/web",
"version": "0.6.0",
"version": "0.7.1",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"dependencies": {
"@apollo/client": "^3.6.9",
"@automatisch/types": "^0.6.0",
"@automatisch/types": "^0.7.1",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.8",
"@mui/icons-material": "^5.11.9",
"@mui/lab": "^5.0.0-alpha.120",
"@mui/material": "^5.11.10",
"@mui/x-date-pickers": "^6.5.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
@@ -30,7 +31,7 @@
"notistack": "^2.0.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.17.2",
"react-hook-form": "^7.43.9",
"react-intl": "^5.20.12",
"react-json-tree": "^0.16.2",
"react-router-dom": "^6.0.2",

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import FormHelperText from '@mui/material/FormHelperText';
import Autocomplete, { AutocompleteProps } from '@mui/material/Autocomplete';
import Autocomplete, { AutocompleteProps, createFilterOptions } from '@mui/material/Autocomplete';
import Typography from '@mui/material/Typography';
import type { IFieldDropdownOption } from '@automatisch/types';
@@ -18,6 +18,14 @@ interface ControlledAutocompleteProps
const getOption = (options: readonly IFieldDropdownOption[], value: string) =>
options.find((option) => option.value === value) || null;
// Enables filtering by value in autocomplete dropdown
const filterOptions = createFilterOptions<IFieldDropdownOption>({
stringify: ({ label, value }) => `
${label}
${value}
`
})
function ControlledAutocomplete(
props: ControlledAutocompleteProps
): React.ReactElement {
@@ -27,7 +35,7 @@ function ControlledAutocomplete(
required = false,
name,
defaultValue,
shouldUnregister = true,
shouldUnregister = false,
onBlur,
onChange,
description,
@@ -75,6 +83,7 @@ function ControlledAutocomplete(
{...autocompleteProps}
{...field}
options={options}
filterOptions={filterOptions}
value={getOption(options, field.value)}
onChange={(event, selectedOption, reason, details) => {
const typedSelectedOption =

View File

@@ -36,7 +36,7 @@ function ExecutionDate(props: Pick<IExecution, 'createdAt'>) {
const relativeCreatedAt = createdAt.toRelative();
return (
<Tooltip title={createdAt.toLocaleString(DateTime.DATE_MED)}>
<Tooltip title={createdAt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}>
<Typography variant="body1" gutterBottom>
{relativeCreatedAt}
</Typography>

View File

@@ -23,8 +23,8 @@ export default function ExecutionRow(
const { execution } = props;
const { flow } = execution;
const createdAt = DateTime.fromMillis(parseInt(execution.createdAt, 10));
const relativeCreatedAt = createdAt.toRelative();
const updatedAt = DateTime.fromMillis(parseInt(execution.updatedAt, 10));
const relativeUpdatedAt = updatedAt.toRelative();
return (
<Link to={URLS.EXECUTION(execution.id)} data-test="execution-row">
@@ -41,8 +41,8 @@ export default function ExecutionRow(
</Typography>
<Typography variant="caption" noWrap>
{formatMessage('execution.executedAt', {
datetime: relativeCreatedAt,
{formatMessage('execution.updatedAt', {
datetime: relativeUpdatedAt,
})}
</Typography>
</Title>

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { DateTime } from 'luxon';
import { useQuery } from '@apollo/client';
import Stack from '@mui/material/Stack';
import ErrorIcon from '@mui/icons-material/Error';
@@ -6,6 +7,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Box from '@mui/material/Box';
import type { IApp, IExecutionStep, IStep } from '@automatisch/types';
@@ -29,6 +31,22 @@ type ExecutionStepProps = {
executionStep: IExecutionStep;
};
function ExecutionStepDate(props: Pick<IExecutionStep, 'createdAt'>) {
const formatMessage = useFormatMessage();
const createdAt = DateTime.fromMillis(parseInt(props.createdAt, 10));
const relativeCreatedAt = createdAt.toRelative();
return (
<Tooltip title={createdAt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}>
<Typography variant="caption" gutterBottom>
{formatMessage('executionStep.executedAt', {
datetime: relativeCreatedAt
})}
</Typography>
</Tooltip>
);
}
const validIcon = <CheckCircleIcon color="success" />;
const errorIcon = <ErrorIcon color="error" />;
@@ -56,7 +74,7 @@ export default function ExecutionStep(
return (
<Wrapper elevation={1} data-test="execution-step">
<Header>
<Stack direction="row" alignItems="center" gap={2}>
<Stack direction="row" gap={2}>
<AppIconWrapper>
<AppIcon url={app?.iconUrl} name={app?.name} />
@@ -65,7 +83,7 @@ export default function ExecutionStep(
</AppIconStatusIconWrapper>
</AppIconWrapper>
<div>
<Box flex="1">
<Typography variant="caption">
{isTrigger
? formatMessage('flowStep.triggerType')
@@ -75,7 +93,11 @@ export default function ExecutionStep(
<Typography variant="body2">
{step.position}. {app?.name}
</Typography>
</div>
</Box>
<Box alignSelf="flex-end">
<ExecutionStepDate createdAt={executionStep.createdAt} />
</Box>
</Stack>
</Header>

View File

@@ -0,0 +1,52 @@
import * as React from 'react';
import { DemoContainer } from '@mui/x-date-pickers/internals/demo';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import Grid from '@mui/material/Grid';
import ConditionalIconButton from 'components/ConditionalIconButton';
import SearchIcon from '@mui/icons-material/Search';
export default function ExecutionFilters() {
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DemoContainer components={['DatePicker']}>
<DatePicker label="From" />
<DatePicker label="Until" />
<TextField
label="By data in/out"
InputProps={{
endAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
variant="outlined"
/>
<Grid
container
item
xs="auto"
sm="auto"
alignItems="center"
order={{ xs: 1, sm: 2 }}
>
<ConditionalIconButton
type="submit"
variant="contained"
color="primary"
size="large"
icon={<SearchIcon />}
data-test="create-flow-button"
>
Search
</ConditionalIconButton>
</Grid>
</DemoContainer>
</LocalizationProvider>
);
}

View File

@@ -7,6 +7,7 @@ import MenuItem from '@mui/material/MenuItem';
import { useSnackbar } from 'notistack';
import { DELETE_FLOW } from 'graphql/mutations/delete-flow';
import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -22,8 +23,26 @@ export default function ContextMenu(
const { flowId, onClose, anchorEl } = props;
const { enqueueSnackbar } = useSnackbar();
const [deleteFlow] = useMutation(DELETE_FLOW);
const [duplicateFlow] = useMutation(
DUPLICATE_FLOW,
{
refetchQueries: ['GetFlows'],
}
);
const formatMessage = useFormatMessage();
const onFlowDuplicate = React.useCallback(async () => {
await duplicateFlow({
variables: { input: { id: flowId } },
});
enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), {
variant: 'success',
});
onClose();
}, [flowId, onClose, duplicateFlow]);
const onFlowDelete = React.useCallback(async () => {
await deleteFlow({
variables: { input: { id: flowId } },
@@ -42,7 +61,9 @@ export default function ContextMenu(
enqueueSnackbar(formatMessage('flow.successfullyDeleted'), {
variant: 'success',
});
}, [flowId, deleteFlow]);
onClose();
}, [flowId, onClose, deleteFlow]);
return (
<Menu
@@ -55,6 +76,8 @@ export default function ContextMenu(
{formatMessage('flow.view')}
</MenuItem>
<MenuItem onClick={onFlowDuplicate}>{formatMessage('flow.duplicate')}</MenuItem>
<MenuItem onClick={onFlowDelete}>{formatMessage('flow.delete')}</MenuItem>
</Menu>
);

View File

@@ -19,7 +19,7 @@ function FlowStepContextMenu(
): React.ReactElement {
const { stepId, onClose, anchorEl, deletable } = props;
const [deleteStep] = useMutation(DELETE_STEP, {
refetchQueries: ['GetFlow'],
refetchQueries: ['GetFlow', 'GetStepWithTestExecutions'],
});
const formatMessage = useFormatMessage();

View File

@@ -83,7 +83,7 @@ const PowerInput = (props: PowerInputProps) => {
name={name}
control={control}
defaultValue={defaultValue}
shouldUnregister={shouldUnregister ?? true}
shouldUnregister={shouldUnregister ?? false}
render={({
field: {
value,

View File

@@ -77,7 +77,7 @@ export default function ResetPasswordForm() {
error={touchedFields.password && !!errors?.password}
helperText={
touchedFields.password && errors?.password?.message
? formatMessage(errors?.password?.message, {
? formatMessage(errors?.password?.message as string, {
inputName: formatMessage('resetPasswordForm.passwordFieldLabel'),
})
: ''
@@ -94,7 +94,7 @@ export default function ResetPasswordForm() {
helperText={
touchedFields.confirmPassword &&
errors?.confirmPassword?.message
? formatMessage(errors?.confirmPassword?.message, {
? formatMessage(errors?.confirmPassword?.message as string, {
inputName: formatMessage(
'resetPasswordForm.confirmPasswordFieldLabel'
),

View File

@@ -101,7 +101,7 @@ function SignUpForm() {
error={touchedFields.fullName && !!errors?.fullName}
helperText={
touchedFields.fullName && errors?.fullName?.message
? formatMessage(errors?.fullName?.message, {
? formatMessage(errors?.fullName?.message as string, {
inputName: formatMessage('signupForm.fullNameFieldLabel'),
})
: ''
@@ -118,7 +118,7 @@ function SignUpForm() {
error={touchedFields.email && !!errors?.email}
helperText={
touchedFields.email && errors?.email?.message
? formatMessage(errors?.email?.message, {
? formatMessage(errors?.email?.message as string, {
inputName: formatMessage('signupForm.emailFieldLabel'),
})
: ''
@@ -134,7 +134,7 @@ function SignUpForm() {
error={touchedFields.password && !!errors?.password}
helperText={
touchedFields.password && errors?.password?.message
? formatMessage(errors?.password?.message, {
? formatMessage(errors?.password?.message as string, {
inputName: formatMessage('signupForm.passwordFieldLabel'),
})
: ''
@@ -151,7 +151,7 @@ function SignUpForm() {
helperText={
touchedFields.confirmPassword &&
errors?.confirmPassword?.message
? formatMessage(errors?.confirmPassword?.message, {
? formatMessage(errors?.confirmPassword?.message as string, {
inputName: formatMessage(
'signupForm.confirmPasswordFieldLabel'
),

View File

@@ -58,7 +58,10 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement {
const editorContext = React.useContext(EditorContext);
const [executeFlow, { data, error, loading, called, reset }] = useMutation(
EXECUTE_FLOW,
{ context: { autoSnackbar: false } }
{
refetchQueries: ['GetStepWithTestExecutions'],
context: { autoSnackbar: false }
}
);
const response = data?.executeFlow?.data;

View File

@@ -38,7 +38,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
required,
name,
defaultValue,
shouldUnregister = true,
shouldUnregister = false,
clickToCopy = false,
readOnly = false,
disabled = false,

View File

@@ -0,0 +1,28 @@
import { gql } from '@apollo/client';
export const DUPLICATE_FLOW = gql`
mutation DuplicateFlow($input: DuplicateFlowInput) {
duplicateFlow(input: $input) {
id
name
active
status
steps {
id
type
key
appKey
iconUrl
webhookUrl
status
position
connection {
id
verified
createdAt
}
parameters
}
}
}
`;

View File

@@ -4,6 +4,7 @@ import { useFormContext } from 'react-hook-form';
import set from 'lodash/set';
import type { UseFormReturn } from 'react-hook-form';
import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import type {
IField,
IFieldDropdownSource,

View File

@@ -46,6 +46,7 @@
"flow.paused": "Paused",
"flow.draft": "Draft",
"flow.successfullyDeleted": "The flow and associated executions have been deleted.",
"flow.successfullyDuplicated": "The flow has been successfully duplicated.",
"flowEditor.publish": "PUBLISH",
"flowEditor.unpublish": "UNPUBLISH",
"flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.",
@@ -68,6 +69,7 @@
"flow.createdAt": "created {datetime}",
"flow.updatedAt": "updated {datetime}",
"flow.view": "View",
"flow.duplicate": "Duplicate",
"flow.delete": "Delete",
"flowStep.triggerType": "Trigger",
"flowStep.actionType": "Action",
@@ -77,12 +79,13 @@
"flowEditor.goBack": "Go back to flows",
"executions.title": "Executions",
"executions.noExecutions": "There is no execution data point to show.",
"execution.executedAt": "executed {datetime}",
"execution.updatedAt": "updated {datetime}",
"execution.test": "Test run",
"execution.statusSuccess": "Success",
"execution.statusFailure": "Failure",
"execution.noDataTitle": "No data",
"execution.noDataMessage": "We successfully ran the execution, but there was no new data to process.",
"executionStep.executedAt": "executed {datetime}",
"profileSettings.title": "My Profile",
"profileSettings.fullName": "Full name",
"profileSettings.email": "Email",

View File

@@ -10,6 +10,7 @@ import PaginationItem from '@mui/material/PaginationItem';
import type { IExecution } from '@automatisch/types';
import NoResultFound from 'components/NoResultFound';
import ExecutionsFilter from 'components/ExecutionsFilter';
import ExecutionRow from 'components/ExecutionRow';
import Container from 'components/Container';
import PageTitle from 'components/PageTitle';
@@ -60,25 +61,22 @@ export default function Executions(): React.ReactElement {
<PageTitle>{formatMessage('executions.title')}</PageTitle>
</Grid>
</Grid>
<Divider sx={{ mt: [2, 0], mb: 2 }} />
<ExecutionsFilter />
<Divider sx={{ mt: 2, mb: 2 }} />
{loading && (
<CircularProgress
data-test="executions-loader"
sx={{ display: 'block', margin: '20px auto' }}
/>
)}
{!loading && !hasExecutions && (
<NoResultFound text={formatMessage('executions.noExecutions')} />
)}
{!loading &&
executions?.map((execution) => (
<ExecutionRow key={execution.id} execution={execution} />
))}
{pageInfo && pageInfo.totalPages > 1 && (
<Pagination
sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}

View File

@@ -1343,6 +1343,13 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.21.0":
version "7.21.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/template@^7.16.7", "@babel/template@^7.3.3":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"
@@ -2944,6 +2951,29 @@
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/utils@^5.12.3":
version "5.13.1"
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.13.1.tgz#86199e46014215f95da046a5ec803f4a39c96eee"
integrity sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==
dependencies:
"@babel/runtime" "^7.21.0"
"@types/prop-types" "^15.7.5"
"@types/react-is" "^18.2.0"
prop-types "^15.8.1"
react-is "^18.2.0"
"@mui/x-date-pickers@^6.5.0":
version "6.5.0"
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-6.5.0.tgz#b71dbf9d8961fb34d9d829a4c6f9159ebb4e9206"
integrity sha512-dRCO1mzHjfOqsa4LdKxiXQnV0cuGiAkliyxSDCdRn6clK2WdF9Oj+1+4Mkx7fcJA61SV1eP4Yg29s0/VDsZKZw==
dependencies:
"@babel/runtime" "^7.21.0"
"@mui/utils" "^5.12.3"
"@types/react-transition-group" "^4.4.6"
clsx "^1.2.1"
prop-types "^15.8.1"
react-transition-group "^4.4.5"
"@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"
@@ -4171,6 +4201,13 @@
dependencies:
"@types/react" "*"
"@types/react-is@^18.2.0":
version "18.2.0"
resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-18.2.0.tgz#2f5137853a46017b3d56447940fb3eb92bbf24a5"
integrity sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.5":
version "4.4.5"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416"
@@ -4178,6 +4215,13 @@
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.6":
version "4.4.6"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e"
integrity sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@16 || 17", "@types/react@^17.0.0":
version "17.0.38"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd"
@@ -14634,10 +14678,10 @@ react-error-overlay@^6.0.10:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6"
integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==
react-hook-form@^7.17.2:
version "7.24.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.24.1.tgz#00e80ca20fe3bb3b86d236d74ed1a4e5f6525228"
integrity sha512-UndVzKetChAsO+qkRo/6vOgaeTP60x324mHQ4iXVgHDvFjd+X/caWW0/QuAqipt8Bs7pyKH8147UQCrPTYFc2g==
react-hook-form@^7.43.9:
version "7.43.9"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.9.tgz#84b56ac2f38f8e946c6032ccb760e13a1037c66d"
integrity sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ==
react-intl@^5.20.12:
version "5.24.3"