Compare commits

...

56 Commits

Author SHA1 Message Date
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
Faruk AYDIN
142b96beb0 Release v0.6.0 2023-04-17 18:35:16 +02:00
Shehab Ghazy
18d07dd3b9 feat: Add postgres schema environment variable (#1047)
feat: Add postgres schema environment variable

---------

Co-authored-by: Faruk AYDIN <omerfaruk26@gmail.com>
2023-04-17 14:56:12 +02:00
89 changed files with 1756 additions and 108 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”) 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: With regard to the Automatisch Software:

View File

@@ -4,7 +4,7 @@ WORKDIR /automatisch
RUN \ RUN \
apk --no-cache add --virtual build-dependencies python3 build-base && \ 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.0 --network-timeout 1000000 && \
rm -rf /usr/local/share/.cache/ && \ rm -rf /usr/local/share/.cache/ && \
apk del build-dependencies 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 # syntax=docker/dockerfile:1
FROM automatischio/automatisch:0.5.0 FROM automatischio/automatisch:0.7.0
WORKDIR /automatisch WORKDIR /automatisch
RUN apk add --no-cache openssl dos2unix 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": [
"packages/*" "packages/*"
], ],
"version": "0.5.0", "version": "0.7.0",
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"command": { "command": {

View File

@@ -12,6 +12,7 @@ const knexConfig = {
database: appConfig.postgresDatabase, database: appConfig.postgresDatabase,
ssl: appConfig.postgresEnableSsl, ssl: appConfig.postgresEnableSsl,
}, },
searchPath: [appConfig.postgresSchema],
pool: { min: 0, max: 20 }, pool: { min: 0, max: 20 },
migrations: { migrations: {
directory: __dirname + '/src/db/migrations', directory: __dirname + '/src/db/migrations',

View File

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

View File

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

View File

@@ -11,13 +11,19 @@ export default {
data: [], data: [],
}; };
const params = { const params: Record<string, unknown> = {
q: `mimeType='application/vnd.google-apps.folder'`, q: `mimeType='application/vnd.google-apps.folder'`,
orderBy: 'createdTime desc', orderBy: 'createdTime desc',
pageToken: undefined as unknown as string, pageToken: undefined as unknown as string,
pageSize: 1000, pageSize: 1000,
driveId: $.step.parameters.driveId,
supportsAllDrives: true,
}; };
if ($.step.parameters.driveId) {
params.includeItemsFromAllDrives = true;
}
do { do {
const { data } = await $.http.get( const { data } = await $.http.get(
`https://www.googleapis.com/drive/v3/files`, `https://www.googleapis.com/drive/v3/files`,

View File

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

View File

@@ -7,15 +7,20 @@ const newFilesInFolder = async ($: IGlobalVariable) => {
} else { } else {
q += ` and parents in 'root'`; q += ` and parents in 'root'`;
} }
const params = { const params: Record<string, unknown> = {
pageToken: undefined as unknown as string, pageToken: undefined as unknown as string,
orderBy: 'createdTime desc', orderBy: 'createdTime desc',
fields: '*', fields: '*',
pageSize: 1000, pageSize: 1000,
q, q,
driveId: $.step.parameters.driveId, driveId: $.step.parameters.driveId,
supportsAllDrives: true,
}; };
if ($.step.parameters.driveId) {
params.includeItemsFromAllDrives = true;
}
do { do {
const { data } = await $.http.get(`/v3/files`, { params }); const { data } = await $.http.get(`/v3/files`, { params });
params.pageToken = data.nextPageToken; params.pageToken = data.nextPageToken;

View File

@@ -1,15 +1,20 @@
import { IGlobalVariable } from '@automatisch/types'; import { IGlobalVariable } from '@automatisch/types';
const newFiles = async ($: IGlobalVariable) => { const newFiles = async ($: IGlobalVariable) => {
const params = { const params: Record<string, unknown> = {
pageToken: undefined as unknown as string, pageToken: undefined as unknown as string,
orderBy: 'createdTime desc', orderBy: 'createdTime desc',
fields: '*', fields: '*',
pageSize: 1000, pageSize: 1000,
q: `mimeType!='application/vnd.google-apps.folder'`, q: `mimeType!='application/vnd.google-apps.folder'`,
driveId: $.step.parameters.driveId, driveId: $.step.parameters.driveId,
supportsAllDrives: true,
}; };
if ($.step.parameters.driveId) {
params.includeItemsFromAllDrives = true;
}
do { do {
const { data } = await $.http.get('/v3/files', { params }); const { data } = await $.http.get('/v3/files', { params });
params.pageToken = data.nextPageToken; params.pageToken = data.nextPageToken;

View File

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

View File

@@ -8,15 +8,20 @@ const newFolders = async ($: IGlobalVariable) => {
q += ` and parents in 'root'`; q += ` and parents in 'root'`;
} }
const params = { const params: Record<string, unknown> = {
pageToken: undefined as unknown as string, pageToken: undefined as unknown as string,
orderBy: 'createdTime desc', orderBy: 'createdTime desc',
fields: '*', fields: '*',
pageSize: 1000, pageSize: 1000,
q, q,
driveId: $.step.parameters.driveId, driveId: $.step.parameters.driveId,
supportsAllDrives: true,
}; };
if ($.step.parameters.driveId) {
params.includeItemsFromAllDrives = true;
}
do { do {
const { data } = await $.http.get(`/v3/files`, { params }); const { data } = await $.http.get(`/v3/files`, { params });
params.pageToken = data.nextPageToken; params.pageToken = data.nextPageToken;

View File

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

View File

@@ -12,15 +12,20 @@ const updatedFiles = async ($: IGlobalVariable) => {
q += ` and parents in 'root'`; q += ` and parents in 'root'`;
} }
const params = { const params: Record<string, unknown> = {
pageToken: undefined as unknown as string, pageToken: undefined as unknown as string,
orderBy: 'modifiedTime desc', orderBy: 'modifiedTime desc',
fields: '*', fields: '*',
pageSize: 1000, pageSize: 1000,
q, q,
driveId: $.step.parameters.driveId, driveId: $.step.parameters.driveId,
supportsAllDrives: true,
}; };
if ($.step.parameters.driveId) {
params.includeItemsFromAllDrives = true;
}
do { do {
const { data } = await $.http.get(`/v3/files`, { params }); const { data } = await $.http.get(`/v3/files`, { params });
params.pageToken = data.nextPageToken; params.pageToken = data.nextPageToken;

View File

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

View File

@@ -0,0 +1,46 @@
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;
}
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 newSpreadsheets from './new-spreadsheets';
import newWorksheets from './new-worksheets';
export default [newSpreadsheets]; export default [newSpreadsheets, newWorksheets];

View File

@@ -1,15 +1,20 @@
import { IGlobalVariable } from '@automatisch/types'; import { IGlobalVariable } from '@automatisch/types';
const newSpreadsheets = async ($: IGlobalVariable) => { const newSpreadsheets = async ($: IGlobalVariable) => {
const params = { const params: Record<string, unknown> = {
pageToken: undefined as unknown as string, pageToken: undefined as unknown as string,
orderBy: 'createdTime desc', orderBy: 'createdTime desc',
q: `mimeType='application/vnd.google-apps.spreadsheet'`, q: `mimeType='application/vnd.google-apps.spreadsheet'`,
fields: '*', fields: '*',
pageSize: 1000, pageSize: 1000,
driveId: $.step.parameters.driveId, driveId: $.step.parameters.driveId,
supportsAllDrives: true,
}; };
if ($.step.parameters.driveId) {
params.includeItemsFromAllDrives = true;
}
do { do {
const { data } = await $.http.get( const { data } = await $.http.get(
'https://www.googleapis.com/drive/v3/files', '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'; import defineAction from '../../../../helpers/define-action';
type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; type TMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -9,6 +10,23 @@ type THeaderEntry = {
type THeaderEntries = 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({ export default defineAction({
name: 'Custom Request', name: 'Custom Request',
key: 'customRequest', key: 'customRequest',
@@ -81,29 +99,51 @@ export default defineAction({
const data = $.step.parameters.data as string; const data = $.step.parameters.data as string;
const url = $.step.parameters.url as string; const url = $.step.parameters.url as string;
const headers = $.step.parameters.headers as THeaderEntries; 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) { return result;
throw new Error( }, {});
`Response is too large. Maximum size is 25MB. Actual size is ${metadataResponse.headers['content-length']}`
);
}
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, url,
method, method,
data, data,
headers: headersObject, headers: headersObject,
}); };
if (!isPossiblyTextBased(contentType)) {
requestData.responseType = 'arraybuffer';
}
const response = await $.http.request(requestData);
throwIfFileSizeExceedsLimit(response.headers['content-length']);
let responseData = response.data; let responseData = response.data;
if (typeof response.data === 'string') { if (!isPossiblyTextBased(contentType)) {
responseData = response.data.replaceAll('\u0000', ''); responseData = Buffer.from(responseData as string).toString('base64');
} }
$.setActionItem({ raw: { data: responseData } }); $.setActionItem({ raw: { data: responseData } });

View File

@@ -1,4 +1,5 @@
import checkModeration from './check-moderation'; import checkModeration from './check-moderation';
import sendPrompt from './send-prompt'; 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 findMessage from './find-message';
import findUserByEmail from './find-user-by-email'; import findUserByEmail from './find-user-by-email';
import sendMessageToChannel from './send-a-message-to-channel'; 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 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 { do {
const response: TResponse = await $.http.get('/conversations.list', { const response: TResponse = await $.http.get('/conversations.list', {
params: { params: {
types: 'public_channel,private_channel,im', types: 'public_channel,private_channel',
cursor: nextCursor, cursor: nextCursor,
limit: 1000, 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'; import defineAction from '../../../../helpers/define-action';
export default defineAction({ export default defineAction({
@@ -8,11 +9,21 @@ export default defineAction({
{ {
label: 'From Number', label: 'From Number',
key: 'fromNumber', key: 'fromNumber',
type: 'string' as const, type: 'dropdown' as const,
required: true, required: true,
description: description:
'The number to send the SMS from. Include country code. Example: 15551234567', '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', label: 'To Number',
@@ -35,14 +46,20 @@ export default defineAction({
async run($) { async run($) {
const requestPath = `/2010-04-01/Accounts/${$.auth.data.accountSid}/Messages.json`; 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 fromNumber = ($.step.parameters.fromNumber as string).trim();
const toNumber = '+' + ($.step.parameters.toNumber 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( const response = await $.http.post(
requestPath, requestPath,
`Body=${messageBody}&From=${fromNumber}&To=${toNumber}` payload,
); );
$.setActionItem({ raw: response.data }); $.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 auth from './auth';
import triggers from './triggers'; import triggers from './triggers';
import actions from './actions'; import actions from './actions';
import dynamicData from './dynamic-data';
export default defineApp({ export default defineApp({
name: 'Twilio', name: 'Twilio',
@@ -17,4 +18,5 @@ export default defineApp({
auth, auth,
triggers, triggers,
actions, 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 defineTrigger from '../../../../helpers/define-trigger';
import fetchMessages from './fetch-messages'; import fetchMessages from './fetch-messages';
export default defineTrigger({ export default defineTrigger({
name: 'Receive SMS', name: 'Receive SMS',
key: 'receiveSms', key: 'receiveSms',
pollInterval: 15, type: 'webhook',
description: 'Triggers when a new SMS is received.', description: 'Triggers when a new SMS is received.',
arguments: [ arguments: [
{ {
label: 'To Number', label: 'To Number',
key: 'toNumber', key: 'phoneNumberSid',
type: 'string', type: 'dropdown' as const,
required: true, required: true,
description: description:
'The number to receive the SMS on. It should be a Twilio number.', '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($); 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

@@ -10,7 +10,9 @@ type AppConfig = {
webhookUrl: string; webhookUrl: string;
appEnv: string; appEnv: string;
isDev: boolean; isDev: boolean;
isProd: boolean;
postgresDatabase: string; postgresDatabase: string;
postgresSchema: string;
postgresPort: number; postgresPort: number;
postgresHost: string; postgresHost: string;
postgresUsername: string; postgresUsername: string;
@@ -79,8 +81,10 @@ const appConfig: AppConfig = {
port, port,
appEnv: appEnv, appEnv: appEnv,
isDev: appEnv === 'development', isDev: appEnv === 'development',
isProd: appEnv === 'production',
version: process.env.npm_package_version, version: process.env.npm_package_version,
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development', postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'), postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
postgresHost: process.env.POSTGRES_HOST || 'localhost', postgresHost: process.env.POSTGRES_HOST || 'localhost',
postgresUsername: postgresUsername:

View File

@@ -85,7 +85,7 @@ export default async (request: IRequest, response: Response) => {
}); });
if (testRun) { if (testRun) {
return response.sendStatus(200); return response.sendStatus(204);
} }
const nextStep = await triggerStep.getNextStep(); const nextStep = await triggerStep.getNextStep();
@@ -104,5 +104,5 @@ export default async (request: IRequest, response: Response) => {
await actionQueue.add(jobName, jobPayload, jobOptions); await actionQueue.add(jobName, jobPayload, jobOptions);
return response.sendStatus(200); return response.sendStatus(204);
}; };

View File

@@ -9,6 +9,7 @@ import updateFlow from './mutations/update-flow';
import updateFlowStatus from './mutations/update-flow-status'; import updateFlowStatus from './mutations/update-flow-status';
import executeFlow from './mutations/execute-flow'; import executeFlow from './mutations/execute-flow';
import deleteFlow from './mutations/delete-flow'; import deleteFlow from './mutations/delete-flow';
import duplicateFlow from './mutations/duplicate-flow';
import createStep from './mutations/create-step'; import createStep from './mutations/create-step';
import updateStep from './mutations/update-step'; import updateStep from './mutations/update-step';
import deleteStep from './mutations/delete-step'; import deleteStep from './mutations/delete-step';
@@ -31,6 +32,7 @@ const mutationResolvers = {
updateFlowStatus, updateFlowStatus,
executeFlow, executeFlow,
deleteFlow, deleteFlow,
duplicateFlow,
createStep, createStep,
updateStep, updateStep,
deleteStep, 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, steps: true,
}, },
}) })
.withSoftDeleted()
.findById(params.executionId) .findById(params.executionId)
.throwIfNotFound(); .throwIfNotFound();

View File

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

View File

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

View File

@@ -1,13 +1,27 @@
const plans = [ import appConfig from '../../config/app';
const testPlans = [
{ {
name: '10k - monthly', name: '10k - monthly',
limit: '10,000', limit: '10,000',
quota: 10000, quota: 10000,
price: '€20', price: '€20',
productId: '47384', 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) { export function getPlanById(id: string) {
return plans.find((plan) => plan.productId === id); 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 ExecutionStep from '../models/execution-step';
import get from 'lodash.get'; 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( export default function computeParameters(
parameters: Step['parameters'], parameters: Step['parameters'],

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import * as Sentry from './helpers/sentry.ee'; import * as Sentry from './helpers/sentry.ee';
import appConfig from './config/app';
Sentry.init(); Sentry.init();
@@ -9,8 +10,12 @@ import './workers/trigger';
import './workers/action'; import './workers/action';
import './workers/email'; import './workers/email';
import './workers/delete-user.ee'; 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'; import telemetry from './helpers/telemetry';
telemetry.setServiceType('worker'); telemetry.setServiceType('worker');

View File

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

View File

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

View File

@@ -23,7 +23,9 @@ Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment
| `WEB_APP_URL` | string | | Can be used to override connection URLs and CORS URL | | `WEB_APP_URL` | string | | Can be used to override connection URLs and CORS URL |
| `WEBHOOK_URL` | string | | Can be used to override webhook URL | | `WEBHOOK_URL` | string | | Can be used to override webhook URL |
| `POSTGRES_DATABASE` | string | `automatisch` | Database Name | | `POSTGRES_DATABASE` | string | `automatisch` | Database Name |
| `POSTGRES_SCHEMA` | string | `public` | Database Schema |
| `POSTGRES_PORT` | number | `5432` | Database Port | | `POSTGRES_PORT` | number | `5432` | Database Port |
| `POSTGRES_ENABLE_SSL` | boolean | `false` | Enable/Disable SSL for the database |
| `POSTGRES_HOST` | string | `postgres` | Database Host | | `POSTGRES_HOST` | string | `postgres` | Database Host |
| `POSTGRES_USERNAME` | string | `automatisch_user` | Database User | | `POSTGRES_USERNAME` | string | `automatisch_user` | Database User |
| `POSTGRES_PASSWORD` | string | | Password of Database User | | `POSTGRES_PASSWORD` | string | | Password of Database User |

View File

@@ -3,6 +3,8 @@ favicon: /favicons/google-sheets.svg
items: items:
- name: New Spreadsheets - name: New Spreadsheets
desc: Triggers when you create a new spreadsheet desc: Triggers when you create a new spreadsheet
- name: New Worksheets
desc: Triggers when you create a new worksheet in a spreadsheet
--- ---
<script setup> <script setup>

View File

@@ -7,6 +7,8 @@ items:
desc: Finds a user by email. desc: Finds a user by email.
- name: Send a message to channel - name: Send a message to channel
desc: Sends a message to a channel you specify. 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> <script setup>

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 ## Render
<a href="https://render.com/deploy?repo=https://github.com/automatisch/automatisch"> <a href="https://render.com/deploy?repo=https://github.com/automatisch/automatisch">

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
{ {
"name": "@automatisch/web", "name": "@automatisch/web",
"version": "0.5.0", "version": "0.7.0",
"license": "See LICENSE file", "license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.", "description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"dependencies": { "dependencies": {
"@apollo/client": "^3.6.9", "@apollo/client": "^3.6.9",
"@automatisch/types": "^0.5.0", "@automatisch/types": "^0.7.0",
"@emotion/react": "^11.4.1", "@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.8", "@hookform/resolvers": "^2.8.8",
@@ -30,7 +30,7 @@
"notistack": "^2.0.2", "notistack": "^2.0.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^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-intl": "^5.20.12",
"react-json-tree": "^0.16.2", "react-json-tree": "^0.16.2",
"react-router-dom": "^6.0.2", "react-router-dom": "^6.0.2",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
required, required,
name, name,
defaultValue, defaultValue,
shouldUnregister = true, shouldUnregister = false,
clickToCopy = false, clickToCopy = false,
readOnly = false, readOnly = false,
disabled = 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 set from 'lodash/set';
import type { UseFormReturn } from 'react-hook-form'; import type { UseFormReturn } from 'react-hook-form';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import type { import type {
IField, IField,
IFieldDropdownSource, IFieldDropdownSource,

View File

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

View File

@@ -14634,10 +14634,10 @@ react-error-overlay@^6.0.10:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6"
integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA== integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==
react-hook-form@^7.17.2: react-hook-form@^7.43.9:
version "7.24.1" version "7.43.9"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.24.1.tgz#00e80ca20fe3bb3b86d236d74ed1a4e5f6525228" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.9.tgz#84b56ac2f38f8e946c6032ccb760e13a1037c66d"
integrity sha512-UndVzKetChAsO+qkRo/6vOgaeTP60x324mHQ4iXVgHDvFjd+X/caWW0/QuAqipt8Bs7pyKH8147UQCrPTYFc2g== integrity sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ==
react-intl@^5.20.12: react-intl@^5.20.12:
version "5.24.3" version "5.24.3"