diff --git a/packages/backend/src/apps/scheduler/common/cron-times.ts b/packages/backend/src/apps/scheduler/common/cron-times.ts new file mode 100644 index 00000000..bcc33254 --- /dev/null +++ b/packages/backend/src/apps/scheduler/common/cron-times.ts @@ -0,0 +1,10 @@ +const cronTimes = { + everyHour: '0 * * * *', + everyHourExcludingWeekends: '0 * * * 1-5', + everyDayAt: (hour: number) => `0 ${hour} * * *`, + everyDayExcludingWeekendsAt: (hour: number) => `0 ${hour} * * 1-5`, + everyWeekOnAndAt: (weekday: number, hour: number) => `0 ${hour} * * ${weekday}`, + everyMonthOnAndAt: (day: number, hour: number) => `0 ${hour} ${day} * *`, +}; + +export default cronTimes; diff --git a/packages/backend/src/apps/scheduler/common/get-date-time-object.ts b/packages/backend/src/apps/scheduler/common/get-date-time-object.ts new file mode 100644 index 00000000..9a48cbf2 --- /dev/null +++ b/packages/backend/src/apps/scheduler/common/get-date-time-object.ts @@ -0,0 +1,14 @@ +import { DateTime } from 'luxon'; + +export default function getDateTimeObjectRepresentation(dateTime: DateTime) { + const defaults = dateTime.toObject(); + + return { + ...defaults, + ISO_date_time: dateTime.toISO(), + pretty_date: dateTime.toLocaleString(DateTime.DATE_MED), + pretty_time: dateTime.toLocaleString(DateTime.TIME_WITH_SECONDS), + pretty_day_of_week: dateTime.toFormat('cccc'), + day_of_week: dateTime.weekday, + }; +} diff --git a/packages/backend/src/apps/scheduler/common/get-next-cron-date-time.ts b/packages/backend/src/apps/scheduler/common/get-next-cron-date-time.ts new file mode 100644 index 00000000..b339006b --- /dev/null +++ b/packages/backend/src/apps/scheduler/common/get-next-cron-date-time.ts @@ -0,0 +1,10 @@ +import { DateTime } from 'luxon'; +import cronParser from 'cron-parser'; + +export default function getNextCronDateTime(cronString: string) { + const cronDate = cronParser.parseExpression(cronString); + const matchingNextCronDateTime = cronDate.next(); + const matchingNextDateTime = DateTime.fromJSDate(matchingNextCronDateTime.toDate()); + + return matchingNextDateTime; +}; diff --git a/packages/backend/src/apps/scheduler/index.ts b/packages/backend/src/apps/scheduler/index.ts index b7ee1a76..60f6c4c4 100644 --- a/packages/backend/src/apps/scheduler/index.ts +++ b/packages/backend/src/apps/scheduler/index.ts @@ -1,15 +1,10 @@ -import Triggers from './triggers'; -import { - IService, - IConnection, - IFlow, - IStep, -} from '@automatisch/types'; - -export default class Scheduler implements IService { - triggers: Triggers; - - constructor(connection: IConnection, flow: IFlow, step: IStep) { - this.triggers = new Triggers(step.parameters); - } -} +export default { + name: "Scheduler", + key: "scheduler", + iconUrl: "{BASE_URL}/apps/scheduler/assets/favicon.svg", + docUrl: "https://automatisch.io/docs/scheduler", + authDocUrl: "https://automatisch.io/docs/connections/scheduler", + primaryColor: "0059F7", + supportsConnections: false, + requiresAuthentication: false, +}; diff --git a/packages/backend/src/apps/scheduler/info.json b/packages/backend/src/apps/scheduler/info.json deleted file mode 100644 index d00807d5..00000000 --- a/packages/backend/src/apps/scheduler/info.json +++ /dev/null @@ -1,608 +0,0 @@ -{ - "name": "Scheduler", - "key": "scheduler", - "iconUrl": "{BASE_URL}/apps/scheduler/assets/favicon.svg", - "docUrl": "https://automatisch.io/docs/scheduler", - "authDocUrl": "https://automatisch.io/docs/connections/scheduler", - "primaryColor": "0059F7", - "supportsConnections": false, - "requiresAuthentication": false, - "triggers": [ - { - "name": "Every hour", - "key": "everyHour", - "description": "Triggers every hour.", - "substeps": [ - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Trigger on weekends?", - "key": "triggersOnWeekend", - "type": "dropdown", - "description": "Should this flow trigger on Saturday and Sunday?", - "required": true, - "value": true, - "variables": false, - "options": [ - { - "label": "Yes", - "value": true - }, - { - "label": "No", - "value": false - } - ] - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - }, - { - "name": "Every day", - "key": "everyDay", - "description": "Triggers every day.", - "substeps": [ - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Trigger on weekends?", - "key": "triggersOnWeekend", - "type": "dropdown", - "description": "Should this flow trigger on Saturday and Sunday?", - "required": true, - "value": true, - "variables": false, - "options": [ - { - "label": "Yes", - "value": true - }, - { - "label": "No", - "value": false - } - ] - }, - { - "label": "Time of day", - "key": "hour", - "type": "dropdown", - "required": true, - "value": null, - "variables": false, - "options": [ - { - "label": "00:00", - "value": 0 - }, - { - "label": "01:00", - "value": 1 - }, - { - "label": "02:00", - "value": 2 - }, - { - "label": "03:00", - "value": 3 - }, - { - "label": "04:00", - "value": 4 - }, - { - "label": "05:00", - "value": 5 - }, - { - "label": "06:00", - "value": 6 - }, - { - "label": "07:00", - "value": 7 - }, - { - "label": "08:00", - "value": 8 - }, - { - "label": "09:00", - "value": 9 - }, - { - "label": "10:00", - "value": 10 - }, - { - "label": "11:00", - "value": 11 - }, - { - "label": "12:00", - "value": 12 - }, - { - "label": "13:00", - "value": 13 - }, - { - "label": "14:00", - "value": 14 - }, - { - "label": "15:00", - "value": 15 - }, - { - "label": "16:00", - "value": 16 - }, - { - "label": "17:00", - "value": 17 - }, - { - "label": "18:00", - "value": 18 - }, - { - "label": "19:00", - "value": 19 - }, - { - "label": "20:00", - "value": 20 - }, - { - "label": "21:00", - "value": 21 - }, - { - "label": "22:00", - "value": 22 - }, - { - "label": "23:00", - "value": 23 - } - ] - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - }, - { - "name": "Every week", - "key": "everyWeek", - "description": "Triggers every week.", - "substeps": [ - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Day of the week", - "key": "weekday", - "type": "dropdown", - "required": true, - "value": null, - "variables": false, - "options": [ - { - "label": "Monday", - "value": 1 - }, - { - "label": "Tuesday", - "value": 2 - }, - { - "label": "Wednesday", - "value": 3 - }, - { - "label": "Thursday", - "value": 4 - }, - { - "label": "Friday", - "value": 5 - }, - { - "label": "Saturday", - "value": 6 - }, - { - "label": "Sunday", - "value": 0 - } - ] - }, - { - "label": "Time of day", - "key": "hour", - "type": "dropdown", - "required": true, - "value": null, - "variables": false, - "options": [ - { - "label": "00:00", - "value": 0 - }, - { - "label": "01:00", - "value": 1 - }, - { - "label": "02:00", - "value": 2 - }, - { - "label": "03:00", - "value": 3 - }, - { - "label": "04:00", - "value": 4 - }, - { - "label": "05:00", - "value": 5 - }, - { - "label": "06:00", - "value": 6 - }, - { - "label": "07:00", - "value": 7 - }, - { - "label": "08:00", - "value": 8 - }, - { - "label": "09:00", - "value": 9 - }, - { - "label": "10:00", - "value": 10 - }, - { - "label": "11:00", - "value": 11 - }, - { - "label": "12:00", - "value": 12 - }, - { - "label": "13:00", - "value": 13 - }, - { - "label": "14:00", - "value": 14 - }, - { - "label": "15:00", - "value": 15 - }, - { - "label": "16:00", - "value": 16 - }, - { - "label": "17:00", - "value": 17 - }, - { - "label": "18:00", - "value": 18 - }, - { - "label": "19:00", - "value": 19 - }, - { - "label": "20:00", - "value": 20 - }, - { - "label": "21:00", - "value": 21 - }, - { - "label": "22:00", - "value": 22 - }, - { - "label": "23:00", - "value": 23 - } - ] - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - }, - { - "name": "Every month", - "key": "everyMonth", - "description": "Triggers every month.", - "substeps": [ - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Day of the month", - "key": "day", - "type": "dropdown", - "required": true, - "value": null, - "variables": false, - "options": [ - { - "label": 1, - "value": 1 - }, - { - "label": 2, - "value": 2 - }, - { - "label": 3, - "value": 3 - }, - { - "label": 4, - "value": 4 - }, - { - "label": 5, - "value": 5 - }, - { - "label": 6, - "value": 6 - }, - { - "label": 7, - "value": 7 - }, - { - "label": 8, - "value": 8 - }, - { - "label": 9, - "value": 9 - }, - { - "label": 10, - "value": 10 - }, - { - "label": 11, - "value": 11 - }, - { - "label": 12, - "value": 12 - }, - { - "label": 13, - "value": 13 - }, - { - "label": 14, - "value": 14 - }, - { - "label": 15, - "value": 15 - }, - { - "label": 16, - "value": 16 - }, - { - "label": 17, - "value": 17 - }, - { - "label": 18, - "value": 18 - }, - { - "label": 19, - "value": 19 - }, - { - "label": 20, - "value": 20 - }, - { - "label": 21, - "value": 21 - }, - { - "label": 22, - "value": 22 - }, - { - "label": 23, - "value": 23 - }, - { - "label": 24, - "value": 24 - }, - { - "label": 25, - "value": 25 - }, - { - "label": 26, - "value": 26 - }, - { - "label": 27, - "value": 27 - }, - { - "label": 28, - "value": 28 - }, - { - "label": 29, - "value": 29 - }, - { - "label": 30, - "value": 30 - }, - { - "label": 31, - "value": 31 - } - ] - }, - { - "label": "Time of day", - "key": "hour", - "type": "dropdown", - "required": true, - "value": null, - "variables": false, - "options": [ - { - "label": "00:00", - "value": 0 - }, - { - "label": "01:00", - "value": 1 - }, - { - "label": "02:00", - "value": 2 - }, - { - "label": "03:00", - "value": 3 - }, - { - "label": "04:00", - "value": 4 - }, - { - "label": "05:00", - "value": 5 - }, - { - "label": "06:00", - "value": 6 - }, - { - "label": "07:00", - "value": 7 - }, - { - "label": "08:00", - "value": 8 - }, - { - "label": "09:00", - "value": 9 - }, - { - "label": "10:00", - "value": 10 - }, - { - "label": "11:00", - "value": 11 - }, - { - "label": "12:00", - "value": 12 - }, - { - "label": "13:00", - "value": 13 - }, - { - "label": "14:00", - "value": 14 - }, - { - "label": "15:00", - "value": 15 - }, - { - "label": "16:00", - "value": 16 - }, - { - "label": "17:00", - "value": 17 - }, - { - "label": "18:00", - "value": 18 - }, - { - "label": "19:00", - "value": 19 - }, - { - "label": "20:00", - "value": 20 - }, - { - "label": "21:00", - "value": 21 - }, - { - "label": "22:00", - "value": 22 - }, - { - "label": "23:00", - "value": 23 - } - ] - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - } - ] -} diff --git a/packages/backend/src/apps/scheduler/triggers.ts b/packages/backend/src/apps/scheduler/triggers.ts deleted file mode 100644 index ef959681..00000000 --- a/packages/backend/src/apps/scheduler/triggers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IStep } from '@automatisch/types'; -import EveryHour from './triggers/every-hour'; -import EveryDay from './triggers/every-day'; -import EveryWeek from './triggers/every-week'; -import EveryMonth from './triggers/every-month'; - -export default class Triggers { - everyHour: EveryHour; - everyDay: EveryDay; - everyWeek: EveryWeek; - everyMonth: EveryMonth; - - constructor(parameters: IStep["parameters"]) { - this.everyHour = new EveryHour(parameters); - this.everyDay = new EveryDay(parameters); - this.everyWeek = new EveryWeek(parameters); - this.everyMonth = new EveryMonth(parameters); - } -} diff --git a/packages/backend/src/apps/scheduler/triggers/every-day.ts b/packages/backend/src/apps/scheduler/triggers/every-day.ts deleted file mode 100644 index dceb2d7e..00000000 --- a/packages/backend/src/apps/scheduler/triggers/every-day.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { DateTime } from 'luxon'; -import type { IStep, IJSONValue, ITrigger } from '@automatisch/types'; -import { cronTimes, getNextCronDateTime, getDateTimeObjectRepresentation } from '../utils'; - -export default class EveryDay implements ITrigger { - triggersOnWeekend?: boolean; - hour?: number; - - constructor(parameters: IStep["parameters"]) { - if (parameters.triggersOnWeekend) { - this.triggersOnWeekend = parameters.triggersOnWeekend as boolean; - } - - if (parameters.hour) { - this.hour = parameters.hour as number; - } - } - - get interval() { - if (this.triggersOnWeekend) { - return cronTimes.everyDayAt(this.hour); - } - - return cronTimes.everyDayExcludingWeekendsAt(this.hour); - } - - async run(startDateTime: Date) { - const dateTime = DateTime.fromJSDate(startDateTime); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } - - async testRun() { - const nextCronDateTime = getNextCronDateTime(this.interval); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } -} diff --git a/packages/backend/src/apps/scheduler/triggers/every-day/index.ts b/packages/backend/src/apps/scheduler/triggers/every-day/index.ts new file mode 100644 index 00000000..faed677a --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-day/index.ts @@ -0,0 +1,170 @@ +import { DateTime } from 'luxon'; +import { IGlobalVariable, IJSONValue } from '@automatisch/types'; +import cronTimes from '../../common/cron-times'; +import getNextCronDateTime from '../../common/get-next-cron-date-time'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object'; + +export default { + name: 'Every day', + key: 'everyDay', + description: 'Triggers every day.', + substeps: [ + { + key: 'chooseTrigger', + name: 'Set up a trigger', + arguments: [ + { + label: 'Trigger on weekends?', + key: 'triggersOnWeekend', + type: 'dropdown', + description: 'Should this flow trigger on Saturday and Sunday?', + required: true, + value: true, + variables: false, + options: [ + { + label: 'Yes', + value: true + }, + { + label: 'No', + value: false + } + ] + }, + { + label: 'Time of day', + key: 'hour', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '00:00', + value: 0 + }, + { + label: '01:00', + value: 1 + }, + { + label: '02:00', + value: 2 + }, + { + label: '03:00', + value: 3 + }, + { + label: '04:00', + value: 4 + }, + { + label: '05:00', + value: 5 + }, + { + label: '06:00', + value: 6 + }, + { + label: '07:00', + value: 7 + }, + { + label: '08:00', + value: 8 + }, + { + label: '09:00', + value: 9 + }, + { + label: '10:00', + value: 10 + }, + { + label: '11:00', + value: 11 + }, + { + label: '12:00', + value: 12 + }, + { + label: '13:00', + value: 13 + }, + { + label: '14:00', + value: 14 + }, + { + label: '15:00', + value: 15 + }, + { + label: '16:00', + value: 16 + }, + { + label: '17:00', + value: 17 + }, + { + label: '18:00', + value: 18 + }, + { + label: '19:00', + value: 19 + }, + { + label: '20:00', + value: 20 + }, + { + label: '21:00', + value: 21 + }, + { + label: '22:00', + value: 22 + }, + { + label: '23:00', + value: 23 + } + ] + } + ] + }, + { + key: 'testStep', + name: 'Test trigger' + } + ], + + getInterval(parameters: IGlobalVariable["db"]["step"]["parameters"]) { + if (parameters.triggersOnWeekend as boolean) { + return cronTimes.everyDayAt(parameters.hour as number); + } + + return cronTimes.everyDayExcludingWeekendsAt(parameters.hour as number); + }, + + async run($: IGlobalVariable, startDateTime: Date) { + const dateTime = DateTime.fromJSDate(startDateTime); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, + + async testRun($: IGlobalVariable) { + const nextCronDateTime = getNextCronDateTime(this.getInterval($.db.step.parameters)); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, +}; diff --git a/packages/backend/src/apps/scheduler/triggers/every-hour.ts b/packages/backend/src/apps/scheduler/triggers/every-hour.ts deleted file mode 100644 index f76c9cf6..00000000 --- a/packages/backend/src/apps/scheduler/triggers/every-hour.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DateTime } from 'luxon'; -import type { IStep, IJSONValue, ITrigger } from '@automatisch/types'; -import { cronTimes, getNextCronDateTime, getDateTimeObjectRepresentation } from '../utils'; - -export default class EveryHour implements ITrigger { - triggersOnWeekend?: boolean | string; - - constructor(parameters: IStep["parameters"]) { - if (parameters.triggersOnWeekend) { - this.triggersOnWeekend = parameters.triggersOnWeekend as string; - } - } - - get interval() { - if (this.triggersOnWeekend) { - return cronTimes.everyHour; - } - - return cronTimes.everyHourExcludingWeekends; - } - - async run(startDateTime: Date) { - const dateTime = DateTime.fromJSDate(startDateTime); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } - - async testRun() { - const nextCronDateTime = getNextCronDateTime(this.interval); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } -} diff --git a/packages/backend/src/apps/scheduler/triggers/every-hour/index.ts b/packages/backend/src/apps/scheduler/triggers/every-hour/index.ts new file mode 100644 index 00000000..e13e7d3d --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-hour/index.ts @@ -0,0 +1,64 @@ +import { DateTime } from 'luxon'; +import { IGlobalVariable, IJSONValue } from '@automatisch/types'; +import cronTimes from '../../common/cron-times'; +import getNextCronDateTime from '../../common/get-next-cron-date-time'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object'; + +export default { + name: 'Every hour', + key: 'everyHour', + description: 'Triggers every hour.', + substeps: [ + { + key: 'chooseTrigger', + name: 'Set up a trigger', + arguments: [ + { + label: 'Trigger on weekends?', + key: 'triggersOnWeekend', + type: 'dropdown', + description: 'Should this flow trigger on Saturday and Sunday?', + required: true, + value: true, + variables: false, + options: [ + { + label: 'Yes', + value: true + }, + { + label: 'No', + value: false + } + ] + } + ] + }, + { + key: 'testStep', + name: 'Test trigger' + } + ], + + getInterval(parameters: IGlobalVariable["db"]["step"]["parameters"]) { + if (parameters.triggersOnWeekend) { + return cronTimes.everyHour + } + + return cronTimes.everyHourExcludingWeekends; + }, + + async run($: IGlobalVariable, startDateTime: Date) { + const dateTime = DateTime.fromJSDate(startDateTime); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, + + async testRun($: IGlobalVariable) { + const nextCronDateTime = getNextCronDateTime(this.getInterval($.db.step.parameters)); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, +}; diff --git a/packages/backend/src/apps/scheduler/triggers/every-month.ts b/packages/backend/src/apps/scheduler/triggers/every-month.ts deleted file mode 100644 index 822e3b31..00000000 --- a/packages/backend/src/apps/scheduler/triggers/every-month.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DateTime } from 'luxon'; -import type { IStep, IJSONValue, ITrigger } from '@automatisch/types'; -import { cronTimes, getNextCronDateTime, getDateTimeObjectRepresentation } from '../utils'; - -export default class EveryMonth implements ITrigger { - day?: number; - hour?: number; - - constructor(parameters: IStep["parameters"]) { - if (parameters.day) { - this.day = parameters.day as number; - } - - if (parameters.hour) { - this.hour = parameters.hour as number; - } - } - - get interval() { - return cronTimes.everyMonthOnAndAt(this.day, this.hour); - } - - async run(startDateTime: Date) { - const dateTime = DateTime.fromJSDate(startDateTime); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } - - async testRun() { - const nextCronDateTime = getNextCronDateTime(this.interval); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } -} diff --git a/packages/backend/src/apps/scheduler/triggers/every-month/index.ts b/packages/backend/src/apps/scheduler/triggers/every-month/index.ts new file mode 100644 index 00000000..203a1393 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-month/index.ts @@ -0,0 +1,283 @@ +import { DateTime } from 'luxon'; +import { IGlobalVariable, IJSONValue } from '@automatisch/types'; +import cronTimes from '../../common/cron-times'; +import getNextCronDateTime from '../../common/get-next-cron-date-time'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object'; + +export default { + name: 'Every month', + key: 'everyMonth', + description: 'Triggers every month.', + substeps: [ + { + key: 'chooseTrigger', + name: 'Set up a trigger', + arguments: [ + { + label: 'Day of the month', + key: 'day', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: 1, + value: 1 + }, + { + label: 2, + value: 2 + }, + { + label: 3, + value: 3 + }, + { + label: 4, + value: 4 + }, + { + label: 5, + value: 5 + }, + { + label: 6, + value: 6 + }, + { + label: 7, + value: 7 + }, + { + label: 8, + value: 8 + }, + { + label: 9, + value: 9 + }, + { + label: 10, + value: 10 + }, + { + label: 11, + value: 11 + }, + { + label: 12, + value: 12 + }, + { + label: 13, + value: 13 + }, + { + label: 14, + value: 14 + }, + { + label: 15, + value: 15 + }, + { + label: 16, + value: 16 + }, + { + label: 17, + value: 17 + }, + { + label: 18, + value: 18 + }, + { + label: 19, + value: 19 + }, + { + label: 20, + value: 20 + }, + { + label: 21, + value: 21 + }, + { + label: 22, + value: 22 + }, + { + label: 23, + value: 23 + }, + { + label: 24, + value: 24 + }, + { + label: 25, + value: 25 + }, + { + label: 26, + value: 26 + }, + { + label: 27, + value: 27 + }, + { + label: 28, + value: 28 + }, + { + label: 29, + value: 29 + }, + { + label: 30, + value: 30 + }, + { + label: 31, + value: 31 + } + ] + }, + { + label: 'Time of day', + key: 'hour', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '00:00', + value: 0 + }, + { + label: '01:00', + value: 1 + }, + { + label: '02:00', + value: 2 + }, + { + label: '03:00', + value: 3 + }, + { + label: '04:00', + value: 4 + }, + { + label: '05:00', + value: 5 + }, + { + label: '06:00', + value: 6 + }, + { + label: '07:00', + value: 7 + }, + { + label: '08:00', + value: 8 + }, + { + label: '09:00', + value: 9 + }, + { + label: '10:00', + value: 10 + }, + { + label: '11:00', + value: 11 + }, + { + label: '12:00', + value: 12 + }, + { + label: '13:00', + value: 13 + }, + { + label: '14:00', + value: 14 + }, + { + label: '15:00', + value: 15 + }, + { + label: '16:00', + value: 16 + }, + { + label: '17:00', + value: 17 + }, + { + label: '18:00', + value: 18 + }, + { + label: '19:00', + value: 19 + }, + { + label: '20:00', + value: 20 + }, + { + label: '21:00', + value: 21 + }, + { + label: '22:00', + value: 22 + }, + { + label: '23:00', + value: 23 + } + ] + } + ] + }, + { + key: 'testStep', + name: 'Test trigger' + } + ], + + getInterval(parameters: IGlobalVariable["db"]["step"]["parameters"]) { + const interval = cronTimes.everyMonthOnAndAt(parameters.day as number, parameters.hour as number); + + return interval; + }, + + async run($: IGlobalVariable, startDateTime: Date) { + const dateTime = DateTime.fromJSDate(startDateTime); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, + + async testRun($: IGlobalVariable) { + const nextCronDateTime = getNextCronDateTime(this.getInterval($.db.step.parameters)); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, +}; diff --git a/packages/backend/src/apps/scheduler/triggers/every-week.ts b/packages/backend/src/apps/scheduler/triggers/every-week.ts deleted file mode 100644 index 06012f7e..00000000 --- a/packages/backend/src/apps/scheduler/triggers/every-week.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DateTime } from 'luxon'; -import type { IStep, IJSONValue, ITrigger } from '@automatisch/types'; -import { cronTimes, getNextCronDateTime, getDateTimeObjectRepresentation } from '../utils'; - -export default class EveryWeek implements ITrigger { - weekday?: number; - hour?: number; - - constructor(parameters: IStep["parameters"]) { - if (parameters.weekday) { - this.weekday = parameters.weekday as number; - } - - if (parameters.hour) { - this.hour = parameters.hour as number; - } - } - - get interval() { - return cronTimes.everyWeekOnAndAt(this.weekday, this.hour); - } - - async run(startDateTime: Date) { - const dateTime = DateTime.fromJSDate(startDateTime); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } - - async testRun() { - const nextCronDateTime = getNextCronDateTime(this.interval); - const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; - - return [dateTimeObjectRepresentation] as IJSONValue; - } -} diff --git a/packages/backend/src/apps/scheduler/triggers/every-week/index.ts b/packages/backend/src/apps/scheduler/triggers/every-week/index.ts new file mode 100644 index 00000000..72479cb3 --- /dev/null +++ b/packages/backend/src/apps/scheduler/triggers/every-week/index.ts @@ -0,0 +1,187 @@ +import { DateTime } from 'luxon'; +import { IGlobalVariable, IJSONValue } from '@automatisch/types'; +import cronTimes from '../../common/cron-times'; +import getNextCronDateTime from '../../common/get-next-cron-date-time'; +import getDateTimeObjectRepresentation from '../../common/get-date-time-object'; + +export default { + name: 'Every week', + key: 'everyWeek', + description: 'Triggers every week.', + substeps: [ + { + key: 'chooseTrigger', + name: 'Set up a trigger', + arguments: [ + { + label: 'Day of the week', + key: 'weekday', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: 'Monday', + value: 1 + }, + { + label: 'Tuesday', + value: 2 + }, + { + label: 'Wednesday', + value: 3 + }, + { + label: 'Thursday', + value: 4 + }, + { + label: 'Friday', + value: 5 + }, + { + label: 'Saturday', + value: 6 + }, + { + label: 'Sunday', + value: 0 + } + ] + }, + { + label: 'Time of day', + key: 'hour', + type: 'dropdown', + required: true, + value: null, + variables: false, + options: [ + { + label: '00:00', + value: 0 + }, + { + label: '01:00', + value: 1 + }, + { + label: '02:00', + value: 2 + }, + { + label: '03:00', + value: 3 + }, + { + label: '04:00', + value: 4 + }, + { + label: '05:00', + value: 5 + }, + { + label: '06:00', + value: 6 + }, + { + label: '07:00', + value: 7 + }, + { + label: '08:00', + value: 8 + }, + { + label: '09:00', + value: 9 + }, + { + label: '10:00', + value: 10 + }, + { + label: '11:00', + value: 11 + }, + { + label: '12:00', + value: 12 + }, + { + label: '13:00', + value: 13 + }, + { + label: '14:00', + value: 14 + }, + { + label: '15:00', + value: 15 + }, + { + label: '16:00', + value: 16 + }, + { + label: '17:00', + value: 17 + }, + { + label: '18:00', + value: 18 + }, + { + label: '19:00', + value: 19 + }, + { + label: '20:00', + value: 20 + }, + { + label: '21:00', + value: 21 + }, + { + label: '22:00', + value: 22 + }, + { + label: '23:00', + value: 23 + } + ] + } + ] + }, + { + key: 'testStep', + name: 'Test trigger' + } + ], + + getInterval(parameters: IGlobalVariable["db"]["step"]["parameters"]) { + const interval = cronTimes.everyWeekOnAndAt(parameters.weekday as number, parameters.hour as number); + + return interval; + }, + + async run($: IGlobalVariable, startDateTime: Date) { + const dateTime = DateTime.fromJSDate(startDateTime); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(dateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, + + async testRun($: IGlobalVariable) { + const nextCronDateTime = getNextCronDateTime(this.getInterval($.db.step.parameters)); + const dateTimeObjectRepresentation = getDateTimeObjectRepresentation(nextCronDateTime) as IJSONValue; + + return [dateTimeObjectRepresentation] as IJSONValue; + }, +}; diff --git a/packages/backend/src/apps/scheduler/utils.ts b/packages/backend/src/apps/scheduler/utils.ts deleted file mode 100644 index 192ba102..00000000 --- a/packages/backend/src/apps/scheduler/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { DateTime } from 'luxon'; -import cronParser from 'cron-parser'; - -export const cronTimes = { - everyHour: '0 * * * *', - everyHourExcludingWeekends: '0 * * * 1-5', - everyDayAt: (hour: number) => `0 ${hour} * * *`, - everyDayExcludingWeekendsAt: (hour: number) => `0 ${hour} * * 1-5`, - everyWeekOnAndAt: (weekday: number, hour: number) => `0 ${hour} * * ${weekday}`, - everyMonthOnAndAt: (day: number, hour: number) => `0 ${hour} ${day} * *`, -}; - -export function getNextCronDateTime(cronString: string) { - const cronDate = cronParser.parseExpression(cronString); - const matchingNextCronDateTime = cronDate.next(); - const matchingNextDateTime = DateTime.fromJSDate(matchingNextCronDateTime.toDate()); - - return matchingNextDateTime; -}; - -export function getDateTimeObjectRepresentation(dateTime: DateTime) { - const defaults = dateTime.toObject(); - - return { - ...defaults, - ISO_date_time: dateTime.toISO(), - pretty_date: dateTime.toLocaleString(DateTime.DATE_MED), - pretty_time: dateTime.toLocaleString(DateTime.TIME_WITH_SECONDS), - pretty_day_of_week: dateTime.toFormat('cccc'), - day_of_week: dateTime.weekday, - }; -} diff --git a/packages/backend/src/apps/slack/actions.ts b/packages/backend/src/apps/slack/actions.ts deleted file mode 100644 index e4063b76..00000000 --- a/packages/backend/src/apps/slack/actions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import SendMessageToChannel from './actions/send-message-to-channel'; -import FindMessage from './actions/find-message'; -import SlackClient from './client'; - -export default class Actions { - client: SlackClient; - sendMessageToChannel: SendMessageToChannel; - findMessage: FindMessage; - - constructor(client: SlackClient) { - this.client = client; - this.sendMessageToChannel = new SendMessageToChannel(client); - this.findMessage = new FindMessage(client); - } -} diff --git a/packages/backend/src/apps/slack/actions/find-message.ts b/packages/backend/src/apps/slack/actions/find-message.ts deleted file mode 100644 index 4185d7b5..00000000 --- a/packages/backend/src/apps/slack/actions/find-message.ts +++ /dev/null @@ -1,26 +0,0 @@ -import SlackClient from '../client'; - -export default class FindMessage { - client: SlackClient; - - constructor(client: SlackClient) { - this.client = client; - } - - async run() { - const parameters = this.client.step.parameters; - const query = parameters.query as string; - const sortBy = parameters.sortBy as string; - const sortDirection = parameters.sortDirection as string; - const count = 1; - - const messages = await this.client.findMessages.run( - query, - sortBy, - sortDirection, - count, - ); - - return messages; - } -} diff --git a/packages/backend/src/apps/slack/actions/find-message/find-message.ts b/packages/backend/src/apps/slack/actions/find-message/find-message.ts new file mode 100644 index 00000000..57c93041 --- /dev/null +++ b/packages/backend/src/apps/slack/actions/find-message/find-message.ts @@ -0,0 +1,50 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +type FindMessageOptions = { + query: string; + sortBy: string; + sortDirection: string; + count: number; +}; + +const findMessage = async ($: IGlobalVariable, options: FindMessageOptions) => { + const message: { + data?: IJSONObject; + error?: IJSONObject; + } = {}; + + const headers = { + Authorization: `Bearer ${$.auth.data.accessToken}`, + }; + + const params = { + query: options.query, + sort: options.sortBy, + sort_dir: options.sortDirection, + count: options.count || 1, + }; + + const response = await $.http.get('/search.messages', { + headers, + params, + }); + + if (response.integrationError) { + message.error = response.integrationError; + return message; + } + + const data = response.data; + + if (!data.ok) { + message.error = data; + return message; + } + + const messages = data.messages.matches; + message.data = messages?.[0]; + + return message; +}; + +export default findMessage; diff --git a/packages/backend/src/apps/slack/actions/find-message/index.ts b/packages/backend/src/apps/slack/actions/find-message/index.ts new file mode 100644 index 00000000..c6132e2c --- /dev/null +++ b/packages/backend/src/apps/slack/actions/find-message/index.ts @@ -0,0 +1,90 @@ +import { IGlobalVariable } from '@automatisch/types'; +import findMessage from './find-message'; + +export default { + name: 'Find message', + key: 'findMessage', + description: 'Find a Slack message using the Slack Search feature.', + substeps: [ + { + key: 'chooseConnection', + name: 'Choose connection', + }, + { + key: 'setupAction', + name: 'Set up action', + arguments: [ + { + label: 'Search Query', + key: 'query', + type: 'string', + required: true, + description: + 'Search query to use for finding matching messages. See the Slack Search Documentation for more information on constructing a query.', + variables: true, + }, + { + label: 'Sort by', + key: 'sortBy', + type: 'dropdown', + description: + 'Sort messages by their match strength or by their date. Default is score.', + required: true, + value: 'score', + variables: false, + options: [ + { + label: 'Match strength', + value: 'score', + }, + { + label: 'Message date time', + value: 'timestamp', + }, + ], + }, + { + label: 'Sort direction', + key: 'sortDirection', + type: 'dropdown', + description: + 'Sort matching messages in ascending or descending order. Default is descending.', + required: true, + value: 'desc', + variables: false, + options: [ + { + label: 'Descending (newest or best match first)', + value: 'desc', + }, + { + label: 'Ascending (oldest or worst match first)', + value: 'asc', + }, + ], + }, + ], + }, + { + key: 'testStep', + name: 'Test action', + }, + ], + + async run($: IGlobalVariable) { + const parameters = $.db.step.parameters; + const query = parameters.query as string; + const sortBy = parameters.sortBy as string; + const sortDirection = parameters.sortDirection as string; + const count = 1; + + const messages = await findMessage($, { + query, + sortBy, + sortDirection, + count, + }); + + return messages; + }, +}; diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts new file mode 100644 index 00000000..d2033c1e --- /dev/null +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts @@ -0,0 +1,59 @@ +import { IGlobalVariable } from '@automatisch/types'; +import postMessage from './post-message'; + +export default { + name: 'Send a message to channel', + key: 'sendMessageToChannel', + description: 'Send a message to a specific channel you specify.', + substeps: [ + { + key: 'chooseConnection', + name: 'Choose connection', + }, + { + key: 'setupAction', + name: 'Set up action', + arguments: [ + { + label: 'Channel', + key: 'channel', + type: 'dropdown', + required: true, + description: 'Pick a channel to send the message to.', + variables: false, + source: { + type: 'query', + name: 'getData', + arguments: [ + { + name: 'key', + value: 'listChannels', + }, + ], + }, + }, + { + label: 'Message text', + key: 'message', + type: 'string', + required: true, + description: 'The content of your new message.', + variables: true, + }, + ], + }, + { + key: 'testStep', + name: 'Test action', + }, + ], + + async run($: IGlobalVariable) { + const channelId = $.db.step.parameters.channel as string; + const text = $.db.step.parameters.message as string; + + const message = await postMessage($, channelId, text); + + return message; + }, +}; diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts new file mode 100644 index 00000000..8a499767 --- /dev/null +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/post-message.ts @@ -0,0 +1,37 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +const postMessage = async ( + $: IGlobalVariable, + channelId: string, + text: string +) => { + const message: { + data: IJSONObject | null | undefined; + error: IJSONObject | null | undefined; + } = { + data: null, + error: null, + }; + + const headers = { + Authorization: `Bearer ${$.auth.data.accessToken}`, + }; + + const params = { + channel: channelId, + text, + }; + + const response = await $.http.post('/chat.postMessage', params, { headers }); + + message.error = response?.integrationError; + message.data = response?.data?.message; + + if (response.data.ok === false) { + message.error = response.data; + } + + return message; +}; + +export default postMessage; diff --git a/packages/backend/src/apps/slack/actions/send-message-to-channel.ts b/packages/backend/src/apps/slack/actions/send-message-to-channel.ts deleted file mode 100644 index e5f290fe..00000000 --- a/packages/backend/src/apps/slack/actions/send-message-to-channel.ts +++ /dev/null @@ -1,18 +0,0 @@ -import SlackClient from '../client'; - -export default class SendMessageToChannel { - client: SlackClient; - - constructor(client: SlackClient) { - this.client = client; - } - - async run() { - const channelId = this.client.step.parameters.channel as string; - const text = this.client.step.parameters.message as string; - - const message = await this.client.postMessageToChannel.run(channelId, text); - - return message; - } -} diff --git a/packages/backend/src/apps/slack/assets/favicon.svg b/packages/backend/src/apps/slack/assets/favicon.svg index 3fbb3e78..c09453bb 100644 --- a/packages/backend/src/apps/slack/assets/favicon.svg +++ b/packages/backend/src/apps/slack/assets/favicon.svg @@ -1,7 +1,6 @@ - +fill="#fff"/> \ No newline at end of file diff --git a/packages/backend/src/apps/slack/auth/index.ts b/packages/backend/src/apps/slack/auth/index.ts new file mode 100644 index 00000000..99c3fdf3 --- /dev/null +++ b/packages/backend/src/apps/slack/auth/index.ts @@ -0,0 +1,100 @@ +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; + +export default { + fields: [ + { + key: 'accessToken', + label: 'Access Token', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: 'Access token of slack that Automatisch will connect to.', + clickToCopy: false, + }, + ], + authenticationSteps: [ + { + step: 1, + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'accessToken', + value: '{fields.accessToken}', + }, + ], + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + ], + reconnectionSteps: [ + { + step: 1, + type: 'mutation', + name: 'resetConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'accessToken', + value: '{fields.accessToken}', + }, + ], + }, + ], + }, + { + step: 3, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + ], + + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/slack/auth/is-still-verified.ts b/packages/backend/src/apps/slack/auth/is-still-verified.ts new file mode 100644 index 00000000..809ad202 --- /dev/null +++ b/packages/backend/src/apps/slack/auth/is-still-verified.ts @@ -0,0 +1,12 @@ +import verifyCredentials from './verify-credentials'; + +const isStillVerified = async ($: any) => { + try { + await verifyCredentials($); + return true; + } catch (error) { + return false; + } +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/slack/auth/verify-credentials.ts b/packages/backend/src/apps/slack/auth/verify-credentials.ts new file mode 100644 index 00000000..0152348a --- /dev/null +++ b/packages/backend/src/apps/slack/auth/verify-credentials.ts @@ -0,0 +1,34 @@ +import qs from 'qs'; +import { IGlobalVariable } from '@automatisch/types'; + +const verifyCredentials = async ($: IGlobalVariable) => { + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const stringifiedBody = qs.stringify({ + token: $.auth.data.accessToken, + }); + + const response = await $.http.post('/auth.test', stringifiedBody, { + headers, + }); + + if (response.data.ok === false) { + throw new Error( + `Error occured while verifying credentials: ${response.data.error}.(More info: https://api.slack.com/methods/auth.test#errors)` + ); + } + + const { bot_id: botId, user: screenName } = response.data; + + $.auth.set({ + botId, + screenName, + token: $.auth.data.accessToken, + }); + + return response.data; +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/slack/authentication.ts b/packages/backend/src/apps/slack/authentication.ts deleted file mode 100644 index b08394f9..00000000 --- a/packages/backend/src/apps/slack/authentication.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { IAuthentication, IJSONObject } from '@automatisch/types'; -import SlackClient from './client'; - -export default class Authentication implements IAuthentication { - client: SlackClient; - - static requestOptions: IJSONObject = { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }; - - constructor(client: SlackClient) { - this.client = client; - } - - async verifyCredentials() { - const { bot_id: botId, user: screenName } = - await this.client.verifyAccessToken.run(); - - return { - botId, - screenName, - token: this.client.connection.formattedData.accessToken, - }; - } - - async isStillVerified() { - try { - await this.client.verifyAccessToken.run(); - return true; - } catch (error) { - return false; - } - } -} diff --git a/packages/backend/src/apps/slack/client/endpoints/find-messages.ts b/packages/backend/src/apps/slack/client/endpoints/find-messages.ts deleted file mode 100644 index 50d99819..00000000 --- a/packages/backend/src/apps/slack/client/endpoints/find-messages.ts +++ /dev/null @@ -1,44 +0,0 @@ -import SlackClient from '../index'; - -export default class FindMessages { - client: SlackClient; - - constructor(client: SlackClient) { - this.client = client; - } - - async run(query: string, sortBy: string, sortDirection: string, count = 1) { - const headers = { - Authorization: `Bearer ${this.client.connection.formattedData.accessToken}`, - }; - - const params = { - query, - sort: sortBy, - sort_dir: sortDirection, - count, - }; - - const response = await this.client.httpClient.get('/search.messages', { - headers, - params, - }); - - const data = response.data; - - if (!data.ok) { - if (data.error === 'missing_scope') { - throw new Error( - `Error occured while finding messages; ${data.error}: ${data.needed}` - ); - } - - throw new Error(`Error occured while finding messages; ${data.error}`); - } - - const messages = data.messages.matches; - const message = messages?.[0]; - - return message; - } -} diff --git a/packages/backend/src/apps/slack/client/endpoints/post-message-to-channel.ts b/packages/backend/src/apps/slack/client/endpoints/post-message-to-channel.ts deleted file mode 100644 index c8f54d7e..00000000 --- a/packages/backend/src/apps/slack/client/endpoints/post-message-to-channel.ts +++ /dev/null @@ -1,44 +0,0 @@ -import SlackClient from '../index'; -import { IJSONObject } from '@automatisch/types'; - -export default class PostMessageToChannel { - client: SlackClient; - - constructor(client: SlackClient) { - this.client = client; - } - - async run(channelId: string, text: string) { - const message: { - data: IJSONObject | null; - error: IJSONObject | null; - } = { - data: null, - error: null, - }; - - const headers = { - Authorization: `Bearer ${this.client.connection.formattedData.accessToken}`, - }; - - const params = { - channel: channelId, - text, - }; - - const response = await this.client.httpClient.post( - '/chat.postMessage', - params, - { headers } - ); - - message.error = response?.integrationError; - message.data = response?.data?.message; - - if (response.data.ok === false) { - message.error = response.data; - } - - return message; - } -} diff --git a/packages/backend/src/apps/slack/client/endpoints/verify-access-token.ts b/packages/backend/src/apps/slack/client/endpoints/verify-access-token.ts deleted file mode 100644 index d5b3bfb5..00000000 --- a/packages/backend/src/apps/slack/client/endpoints/verify-access-token.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import qs from 'qs'; -import SlackClient from '../index'; - -export default class VerifyAccessToken { - client: SlackClient; - - static requestOptions: IJSONObject = { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }; - - constructor(client: SlackClient) { - this.client = client; - } - - async run() { - const response = await this.client.httpClient.post( - '/auth.test', - qs.stringify({ - token: this.client.connection.formattedData.accessToken, - }), - VerifyAccessToken.requestOptions - ); - - if (response.data.ok === false) { - throw new Error( - `Error occured while verifying credentials: ${response.data.error}.(More info: https://api.slack.com/methods/auth.test#errors)` - ); - } - - return response.data; - } -} diff --git a/packages/backend/src/apps/slack/client/index.ts b/packages/backend/src/apps/slack/client/index.ts deleted file mode 100644 index 5e5d79e3..00000000 --- a/packages/backend/src/apps/slack/client/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IFlow, IStep, IConnection } from '@automatisch/types'; -import createHttpClient, { IHttpClient } from '../../../helpers/http-client'; -import VerifyAccessToken from './endpoints/verify-access-token'; -import PostMessageToChannel from './endpoints/post-message-to-channel'; -import FindMessages from './endpoints/find-messages'; - -export default class SlackClient { - flow: IFlow; - step: IStep; - connection: IConnection; - httpClient: IHttpClient; - - verifyAccessToken: VerifyAccessToken; - postMessageToChannel: PostMessageToChannel; - findMessages: FindMessages; - - static baseUrl = 'https://slack.com/api'; - - constructor(connection: IConnection, flow?: IFlow, step?: IStep) { - this.connection = connection; - this.flow = flow; - this.step = step; - - this.httpClient = createHttpClient({ baseURL: SlackClient.baseUrl }); - this.verifyAccessToken = new VerifyAccessToken(this); - this.postMessageToChannel = new PostMessageToChannel(this); - this.findMessages = new FindMessages(this); - } -} diff --git a/packages/backend/src/apps/slack/data.ts b/packages/backend/src/apps/slack/data.ts deleted file mode 100644 index d2743b00..00000000 --- a/packages/backend/src/apps/slack/data.ts +++ /dev/null @@ -1,12 +0,0 @@ -import ListChannels from './data/list-channels'; -import SlackClient from './client'; - -export default class Data { - client: SlackClient; - listChannels: ListChannels; - - constructor(client: SlackClient) { - this.client = client; - this.listChannels = new ListChannels(client); - } -} diff --git a/packages/backend/src/apps/slack/data/list-channels.ts b/packages/backend/src/apps/slack/data/list-channels.ts deleted file mode 100644 index e6b8593b..00000000 --- a/packages/backend/src/apps/slack/data/list-channels.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import SlackClient from '../client'; - -export default class ListChannels { - client: SlackClient; - - constructor(client: SlackClient) { - this.client = client; - } - - async run() { - const response = await this.client.httpClient.get('/conversations.list', { - headers: { - Authorization: `Bearer ${this.client.connection.formattedData.accessToken}`, - }, - }); - - if (response.data.ok === 'false') { - throw new Error( - `Error occured while fetching slack channels: ${response.data.error}` - ); - } - - return response.data.channels.map((channel: IJSONObject) => { - return { - value: channel.id, - name: channel.name, - }; - }); - } -} diff --git a/packages/backend/src/apps/slack/data/list-channels/index.ts b/packages/backend/src/apps/slack/data/list-channels/index.ts new file mode 100644 index 00000000..d9fa4ba3 --- /dev/null +++ b/packages/backend/src/apps/slack/data/list-channels/index.ts @@ -0,0 +1,41 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; + +export default { + name: 'List channels', + key: 'listChannels', + + async run($: IGlobalVariable) { + const channels: { + data: IJSONObject[]; + error: IJSONObject | null; + } = { + data: [], + error: null, + }; + + const response = await $.http.get('/conversations.list', { + headers: { + Authorization: `Bearer ${$.auth.data.accessToken}`, + }, + }); + + if (response.integrationError) { + channels.error = response.integrationError; + return channels; + } + + if (response.data.ok === 'false') { + channels.error = response.data.error; + return channels; + } + + channels.data = response.data.channels.map((channel: IJSONObject) => { + return { + value: channel.id, + name: channel.name, + }; + }); + + return channels; + }, +}; diff --git a/packages/backend/src/apps/slack/index.ts b/packages/backend/src/apps/slack/index.ts index 6736e09a..644c8135 100644 --- a/packages/backend/src/apps/slack/index.ts +++ b/packages/backend/src/apps/slack/index.ts @@ -1,30 +1,8 @@ -import { - IService, - IAuthentication, - IConnection, - IFlow, - IStep, -} from '@automatisch/types'; -import Authentication from './authentication'; -import Triggers from './triggers'; -import Actions from './actions'; -import Data from './data'; -import SlackClient from './client'; - -export default class Slack implements IService { - client: SlackClient; - - authenticationClient: IAuthentication; - triggers: Triggers; - actions: Actions; - data: Data; - - constructor(connection: IConnection, flow?: IFlow, step?: IStep) { - this.client = new SlackClient(connection, flow, step); - - this.authenticationClient = new Authentication(this.client); - // this.triggers = new Triggers(this.client); - this.actions = new Actions(this.client); - this.data = new Data(this.client); - } -} +export default { + name: 'Slack', + key: 'slack', + iconUrl: '{BASE_URL}/apps/slack/assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/connections/slack', + supportsConnections: true, + baseUrl: 'https://slack.com/api', +}; diff --git a/packages/backend/src/apps/slack/info.json b/packages/backend/src/apps/slack/info.json deleted file mode 100644 index 86c16f97..00000000 --- a/packages/backend/src/apps/slack/info.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "name": "Slack", - "key": "slack", - "iconUrl": "{BASE_URL}/apps/slack/assets/favicon.svg", - "docUrl": "https://automatisch.io/docs/slack", - "authDocUrl": "https://automatisch.io/docs/connections/slack", - "primaryColor": "2DAAE1", - "supportsConnections": true, - "fields": [ - { - "key": "accessToken", - "label": "Access Token", - "type": "string", - "required": true, - "readOnly": false, - "value": null, - "placeholder": null, - "description": "Access token of slack that Automatisch will connect to.", - "clickToCopy": false - } - ], - "authenticationSteps": [ - { - "step": 1, - "type": "mutation", - "name": "createConnection", - "arguments": [ - { - "name": "key", - "value": "{key}" - }, - { - "name": "formattedData", - "value": null, - "properties": [ - { - "name": "accessToken", - "value": "{fields.accessToken}" - } - ] - } - ] - }, - { - "step": 2, - "type": "mutation", - "name": "verifyConnection", - "arguments": [ - { - "name": "id", - "value": "{createConnection.id}" - } - ] - } - ], - "reconnectionSteps": [ - { - "step": 1, - "type": "mutation", - "name": "resetConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - } - ] - }, - { - "step": 2, - "type": "mutation", - "name": "updateConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - }, - { - "name": "formattedData", - "value": null, - "properties": [ - { - "name": "accessToken", - "value": "{fields.accessToken}" - } - ] - } - ] - }, - { - "step": 3, - "type": "mutation", - "name": "verifyConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - } - ] - } - ], - "triggers": [ - { - "name": "New message posted to a channel", - "key": "newMessageToChannel", - "pollInterval": 15, - "description": "Triggers when a new message is posted to a channel", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Channel", - "key": "channel", - "type": "dropdown", - "required": true, - "variables": false, - "source": { - "type": "query", - "name": "getData", - "arguments": [ - { - "name": "key", - "value": "listChannels" - } - ] - } - }, - { - "label": "Trigger for Bot Messages?", - "key": "triggerForBotMessages", - "type": "dropdown", - "description": "Should this flow trigger for bot messages?", - "required": true, - "value": true, - "variables": false, - "options": [ - { - "label": "Yes", - "value": true - }, - { - "label": "No", - "value": false - } - ] - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - } - ], - "actions": [ - { - "name": "Send a message to channel", - "key": "sendMessageToChannel", - "description": "Send a message to a specific channel you specify.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "setupAction", - "name": "Set up action", - "arguments": [ - { - "label": "Channel", - "key": "channel", - "type": "dropdown", - "required": true, - "description": "Pick a channel to send the message to.", - "variables": false, - "source": { - "type": "query", - "name": "getData", - "arguments": [ - { - "name": "key", - "value": "listChannels" - } - ] - } - }, - { - "label": "Message text", - "key": "message", - "type": "string", - "required": true, - "description": "The content of your new message.", - "variables": true - } - ] - }, - { - "key": "testStep", - "name": "Test action" - } - ] - }, - { - "name": "Find message", - "key": "findMessage", - "description": "Find a Slack message using the Slack Search feature.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "setupAction", - "name": "Set up action", - "arguments": [ - { - "label": "Search Query", - "key": "query", - "type": "string", - "required": true, - "description": "Search query to use for finding matching messages. See the Slack Search Documentation for more information on constructing a query.", - "variables": true - }, - { - "label": "Sort by", - "key": "sortBy", - "type": "dropdown", - "description": "Sort messages by their match strength or by their date. Default is score.", - "required": true, - "value": "score", - "variables": false, - "options": [ - { - "label": "Match strength", - "value": "score" - }, - { - "label": "Message date time", - "value": "timestamp" - } - ] - }, - { - "label": "Sort direction", - "key": "sortDirection", - "type": "dropdown", - "description": "Sort matching messages in ascending or descending order. Default is descending.", - "required": true, - "value": "desc", - "variables": false, - "options": [ - { - "label": "Descending (newest or best match first)", - "value": "desc" - }, - { - "label": "Ascending (oldest or worst match first)", - "value": "asc" - } - ] - } - ] - }, - { - "key": "testStep", - "name": "Test action" - } - ] - } - ] -} diff --git a/packages/backend/src/apps/slack/triggers.ts b/packages/backend/src/apps/slack/triggers.ts deleted file mode 100644 index 8e5862e8..00000000 --- a/packages/backend/src/apps/slack/triggers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import NewMessageToChannel from './triggers/new-message-to-channel'; - -export default class Triggers { - newMessageToChannel: NewMessageToChannel; - - constructor(connectionData: IJSONObject, parameters: IJSONObject) { - this.newMessageToChannel = new NewMessageToChannel( - connectionData, - parameters - ); - } -} diff --git a/packages/backend/src/apps/slack/triggers/new-message-to-channel.ts b/packages/backend/src/apps/slack/triggers/new-message-to-channel.ts deleted file mode 100644 index af32c0d7..00000000 --- a/packages/backend/src/apps/slack/triggers/new-message-to-channel.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import axios, { AxiosInstance } from 'axios'; - -export default class NewMessageToChannel { - httpClient: AxiosInstance; - parameters: IJSONObject; - connectionData: IJSONObject; - BASE_URL = 'https://slack.com/api'; - - constructor(connectionData: IJSONObject, parameters: IJSONObject) { - this.httpClient = axios.create({ baseURL: this.BASE_URL }); - this.connectionData = connectionData; - this.parameters = parameters; - } - - async run() { - // TODO: Fix after webhook implementation. - } - - async testRun() { - const headers = { - Authorization: `Bearer ${this.connectionData.accessToken}`, - }; - - const params = { - channel: this.parameters.channel, - }; - - const response = await this.httpClient.get('/conversations.history', { - headers, - params, - }); - - let lastMessage; - - if (this.parameters.triggerForBotMessages) { - lastMessage = response.data.messages[0]; - } else { - lastMessage = response.data.messages.find( - (message: IJSONObject) => - !Object.prototype.hasOwnProperty.call(message, 'bot_id') - ); - } - - return [lastMessage]; - } -} diff --git a/packages/backend/src/apps/twitter/actions.ts b/packages/backend/src/apps/twitter/actions.ts deleted file mode 100644 index 2b3aa93d..00000000 --- a/packages/backend/src/apps/twitter/actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import TwitterClient from './client'; -import CreateTweet from './actions/create-tweet'; - -export default class Actions { - client: TwitterClient; - createTweet: CreateTweet; - - constructor(client: TwitterClient) { - this.client = client; - this.createTweet = new CreateTweet(client); - } -} diff --git a/packages/backend/src/apps/twitter/actions/create-tweet.ts b/packages/backend/src/apps/twitter/actions/create-tweet.ts deleted file mode 100644 index 482ae0b4..00000000 --- a/packages/backend/src/apps/twitter/actions/create-tweet.ts +++ /dev/null @@ -1,17 +0,0 @@ -import TwitterClient from '../client'; - -export default class CreateTweet { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run() { - const tweet = await this.client.createTweet.run( - this.client.step.parameters.tweet as string - ); - - return tweet; - } -} diff --git a/packages/backend/src/apps/twitter/assets/favicon.svg b/packages/backend/src/apps/twitter/assets/favicon.svg index 752cdc8d..576611f2 100644 --- a/packages/backend/src/apps/twitter/assets/favicon.svg +++ b/packages/backend/src/apps/twitter/assets/favicon.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/packages/backend/src/apps/twitter/auth/create-auth-data.ts b/packages/backend/src/apps/twitter/auth/create-auth-data.ts new file mode 100644 index 00000000..b8fe115f --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/create-auth-data.ts @@ -0,0 +1,35 @@ +import generateRequest from '../common/generate-request'; +import { IJSONObject, IField, IGlobalVariable } from '@automatisch/types'; +import { URLSearchParams } from 'url'; + +export default async function createAuthData($: IGlobalVariable) { + try { + const oauthRedirectUrlField = $.app.auth.fields.find( + (field: IField) => field.key == 'oAuthRedirectUrl' + ); + + const callbackUrl = oauthRedirectUrlField.value; + + const response = await generateRequest($, { + requestPath: '/oauth/request_token', + method: 'POST', + data: { oauth_callback: callbackUrl }, + }); + + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + url: `${$.app.baseUrl}/oauth/authorize?oauth_token=${responseData.oauth_token}`, + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + }); + } catch (error) { + const errorMessages = error.response.data.errors + .map((error: IJSONObject) => error.message) + .join(' '); + + throw new Error( + `Error occured while verifying credentials: ${errorMessages}` + ); + } +} diff --git a/packages/backend/src/apps/twitter/auth/index.ts b/packages/backend/src/apps/twitter/auth/index.ts new file mode 100644 index 00000000..a5690b2f --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/index.ts @@ -0,0 +1,219 @@ +import createAuthData from './create-auth-data'; +import verifyCredentials from './verify-credentials'; +import isStillVerified from './is-still-verified'; + +export default { + fields: [ + { + key: 'oAuthRedirectUrl', + label: 'OAuth Redirect URL', + type: 'string', + required: true, + readOnly: true, + value: '{WEB_APP_URL}/app/twitter/connections/add', + placeholder: null, + description: + 'When asked to input an OAuth callback or redirect URL in Twitter OAuth, enter the URL above.', + clickToCopy: true, + }, + { + key: 'consumerKey', + label: 'API Key', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + { + key: 'consumerSecret', + label: 'API Secret', + type: 'string', + required: true, + readOnly: false, + value: null, + placeholder: null, + description: null, + clickToCopy: false, + }, + ], + authenticationSteps: [ + { + step: 1, + type: 'mutation', + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'consumerKey', + value: '{fields.consumerKey}', + }, + { + name: 'consumerSecret', + value: '{fields.consumerSecret}', + }, + ], + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'createAuthData', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + { + step: 3, + type: 'openWithPopup', + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{createAuthData.url}', + }, + ], + }, + { + step: 4, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'oauthVerifier', + value: '{openAuthPopup.oauth_verifier}', + }, + ], + }, + ], + }, + { + step: 5, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + ], + reconnectionSteps: [ + { + step: 1, + type: 'mutation', + name: 'resetConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 2, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'consumerKey', + value: '{fields.consumerKey}', + }, + { + name: 'consumerSecret', + value: '{fields.consumerSecret}', + }, + ], + }, + ], + }, + { + step: 3, + type: 'mutation', + name: 'createAuthData', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + { + step: 4, + type: 'openWithPopup', + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{createAuthData.url}', + }, + ], + }, + { + step: 5, + type: 'mutation', + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + { + name: 'formattedData', + value: null, + properties: [ + { + name: 'oauthVerifier', + value: '{openAuthPopup.oauth_verifier}', + }, + ], + }, + ], + }, + { + step: 6, + type: 'mutation', + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{connection.id}', + }, + ], + }, + ], + + createAuthData, + verifyCredentials, + isStillVerified, +}; diff --git a/packages/backend/src/apps/twitter/auth/is-still-verified.ts b/packages/backend/src/apps/twitter/auth/is-still-verified.ts new file mode 100644 index 00000000..6ecd7ecd --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/is-still-verified.ts @@ -0,0 +1,13 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getCurrentUser from '../common/get-current-user'; + +const isStillVerified = async ($: IGlobalVariable) => { + try { + await getCurrentUser($); + return true; + } catch (error) { + return false; + } +}; + +export default isStillVerified; diff --git a/packages/backend/src/apps/twitter/auth/verify-credentials.ts b/packages/backend/src/apps/twitter/auth/verify-credentials.ts new file mode 100644 index 00000000..4d50b42d --- /dev/null +++ b/packages/backend/src/apps/twitter/auth/verify-credentials.ts @@ -0,0 +1,24 @@ +import { IGlobalVariable } from '@automatisch/types'; +import { URLSearchParams } from 'url'; + +const verifyCredentials = async ($: IGlobalVariable) => { + try { + const response = await $.http.post( + `/oauth/access_token?oauth_verifier=${$.auth.data.oauthVerifier}&oauth_token=${$.auth.data.accessToken}`, + null + ); + + const responseData = Object.fromEntries(new URLSearchParams(response.data)); + + await $.auth.set({ + accessToken: responseData.oauth_token, + accessSecret: responseData.oauth_token_secret, + userId: responseData.user_id, + screenName: responseData.screen_name, + }); + } catch (error) { + throw new Error(error.response.data); + } +}; + +export default verifyCredentials; diff --git a/packages/backend/src/apps/twitter/authentication.ts b/packages/backend/src/apps/twitter/authentication.ts deleted file mode 100644 index 5ddb83d7..00000000 --- a/packages/backend/src/apps/twitter/authentication.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { IAuthentication, IField } from '@automatisch/types'; -import { URLSearchParams } from 'url'; -import TwitterClient from './client'; - -export default class Authentication implements IAuthentication { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async createAuthData() { - const appFields = this.client.connection.appData.fields.find( - (field: IField) => field.key == 'oAuthRedirectUrl' - ); - const callbackUrl = appFields.value; - - const response = await this.client.oauthRequestToken.run(callbackUrl); - const responseData = Object.fromEntries(new URLSearchParams(response.data)); - - return { - url: `${TwitterClient.baseUrl}/oauth/authorize?oauth_token=${responseData.oauth_token}`, - accessToken: responseData.oauth_token, - accessSecret: responseData.oauth_token_secret, - }; - } - - async verifyCredentials() { - const response = await this.client.verifyAccessToken.run(); - const responseData = Object.fromEntries(new URLSearchParams(response.data)); - - return { - consumerKey: this.client.connection.formattedData.consumerKey as string, - consumerSecret: this.client.connection.formattedData - .consumerSecret as string, - accessToken: responseData.oauth_token, - accessSecret: responseData.oauth_token_secret, - userId: responseData.user_id, - screenName: responseData.screen_name, - }; - } - - async isStillVerified() { - try { - await this.client.getCurrentUser.run(); - return true; - } catch (error) { - return false; - } - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/create-tweet.ts b/packages/backend/src/apps/twitter/client/endpoints/create-tweet.ts deleted file mode 100644 index 52eade01..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/create-tweet.ts +++ /dev/null @@ -1,40 +0,0 @@ -import TwitterClient from '../index'; - -export default class CreateTweet { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(text: string) { - try { - const token = { - key: this.client.connection.formattedData.accessToken as string, - secret: this.client.connection.formattedData.accessSecret as string, - }; - - const requestData = { - url: `${TwitterClient.baseUrl}/2/tweets`, - method: 'POST', - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData, token) - ); - - const response = await this.client.httpClient.post( - `/2/tweets`, - { text }, - { headers: { ...authHeader } } - ); - - const tweet = response.data.data; - - return tweet; - } catch (error) { - const errorMessage = error.response.data.detail; - throw new Error(`Error occured while creating a tweet: ${errorMessage}`); - } - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/get-current-user.ts b/packages/backend/src/apps/twitter/client/endpoints/get-current-user.ts deleted file mode 100644 index 4ccaa575..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/get-current-user.ts +++ /dev/null @@ -1,35 +0,0 @@ -import TwitterClient from '../index'; - -export default class GetCurrentUser { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run() { - const token = { - key: this.client.connection.formattedData.accessToken as string, - secret: this.client.connection.formattedData.accessSecret as string, - }; - - const requestPath = '/2/users/me'; - - const requestData = { - url: `${TwitterClient.baseUrl}${requestPath}`, - method: 'GET', - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData, token) - ); - - const response = await this.client.httpClient.get(requestPath, { - headers: { ...authHeader }, - }); - - const currentUser = response.data.data; - - return currentUser; - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/get-user-by-username.ts b/packages/backend/src/apps/twitter/client/endpoints/get-user-by-username.ts deleted file mode 100644 index 23ac8b83..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/get-user-by-username.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import TwitterClient from '../index'; - -export default class GetUserByUsername { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(username: string) { - const token = { - key: this.client.connection.formattedData.accessToken as string, - secret: this.client.connection.formattedData.accessSecret as string, - }; - - const requestPath = `/2/users/by/username/${username}`; - - const requestData = { - url: `${TwitterClient.baseUrl}${requestPath}`, - method: 'GET', - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData, token) - ); - - const response = await this.client.httpClient.get(requestPath, { - headers: { ...authHeader }, - }); - - if (response.data?.errors) { - const errorMessages = response.data.errors - .map((error: IJSONObject) => error.detail) - .join(' '); - - throw new Error( - `Error occured while fetching user data: ${errorMessages}` - ); - } - - const user = response.data.data; - return user; - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/get-user-followers.ts b/packages/backend/src/apps/twitter/client/endpoints/get-user-followers.ts deleted file mode 100644 index 543a007f..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/get-user-followers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import { URLSearchParams } from 'url'; -import TwitterClient from '../index'; -import omitBy from 'lodash/omitBy'; -import isEmpty from 'lodash/isEmpty'; - -export default class GetUserFollowers { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(userId: string, lastInternalId?: string) { - const token = { - key: this.client.connection.formattedData.accessToken as string, - secret: this.client.connection.formattedData.accessSecret as string, - }; - - let response; - const followers: IJSONObject[] = []; - - do { - const params: IJSONObject = { - pagination_token: response?.data?.meta?.next_token, - }; - - const queryParams = new URLSearchParams(omitBy(params, isEmpty)); - - const requestPath = `/2/users/${userId}/followers${ - queryParams.toString() ? `?${queryParams.toString()}` : '' - }`; - - const requestData = { - url: `${TwitterClient.baseUrl}${requestPath}`, - method: 'GET', - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData, token) - ); - - response = await this.client.httpClient.get(requestPath, { - headers: { ...authHeader }, - }); - - if (response.data.meta.result_count > 0) { - response.data.data.forEach((tweet: IJSONObject) => { - if (!lastInternalId || Number(tweet.id) > Number(lastInternalId)) { - followers.push(tweet); - } else { - return; - } - }); - } - } while (response.data.meta.next_token && lastInternalId); - - if (response.data?.errors) { - const errorMessages = response.data.errors - .map((error: IJSONObject) => error.detail) - .join(' '); - - throw new Error( - `Error occured while fetching user data: ${errorMessages}` - ); - } - - return followers; - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/get-user-tweets.ts b/packages/backend/src/apps/twitter/client/endpoints/get-user-tweets.ts deleted file mode 100644 index b6343ced..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/get-user-tweets.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import { URLSearchParams } from 'url'; -import TwitterClient from '../index'; -import omitBy from 'lodash/omitBy'; -import isEmpty from 'lodash/isEmpty'; - -export default class GetUserTweets { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(userId: string, lastInternalId?: string) { - const token = { - key: this.client.connection.formattedData.accessToken as string, - secret: this.client.connection.formattedData.accessSecret as string, - }; - - let response; - const tweets: IJSONObject[] = []; - - do { - const params: IJSONObject = { - since_id: lastInternalId, - pagination_token: response?.data?.meta?.next_token, - }; - - const queryParams = new URLSearchParams(omitBy(params, isEmpty)); - - const requestPath = `/2/users/${userId}/tweets${ - queryParams.toString() ? `?${queryParams.toString()}` : '' - }`; - - const requestData = { - url: `${TwitterClient.baseUrl}${requestPath}`, - method: 'GET', - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData, token) - ); - - response = await this.client.httpClient.get(requestPath, { - headers: { ...authHeader }, - }); - - if (response.data.meta.result_count > 0) { - response.data.data.forEach((tweet: IJSONObject) => { - if (!lastInternalId || Number(tweet.id) > Number(lastInternalId)) { - tweets.push(tweet); - } else { - return; - } - }); - } - } while (response.data.meta.next_token && lastInternalId); - - if (response.data?.errors) { - const errorMessages = response.data.errors - .map((error: IJSONObject) => error.detail) - .join(' '); - - throw new Error( - `Error occured while fetching user data: ${errorMessages}` - ); - } - - return tweets; - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/oauth-request-token.ts b/packages/backend/src/apps/twitter/client/endpoints/oauth-request-token.ts deleted file mode 100644 index 6ef9d899..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/oauth-request-token.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import TwitterClient from '../index'; - -export default class OAuthRequestToken { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(callbackUrl: string) { - try { - const requestData = { - url: `${TwitterClient.baseUrl}/oauth/request_token`, - method: 'POST', - data: { oauth_callback: callbackUrl }, - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData) - ); - - const response = await this.client.httpClient.post( - `/oauth/request_token`, - null, - { - headers: { ...authHeader }, - } - ); - - return response; - } catch (error) { - const errorMessages = error.response.data.errors - .map((error: IJSONObject) => error.message) - .join(' '); - - throw new Error( - `Error occured while verifying credentials: ${errorMessages}` - ); - } - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/search-tweets.ts b/packages/backend/src/apps/twitter/client/endpoints/search-tweets.ts deleted file mode 100644 index 89eddeba..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/search-tweets.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; -import { URLSearchParams } from 'url'; -import TwitterClient from '../index'; -import omitBy from 'lodash/omitBy'; -import isEmpty from 'lodash/isEmpty'; -import qs from 'qs'; - -export default class SearchTweets { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(searchTerm: string, lastInternalId?: string) { - const token = { - key: this.client.connection.formattedData.accessToken as string, - secret: this.client.connection.formattedData.accessSecret as string, - }; - - let response; - const tweets: { - data: IJSONObject[]; - error: IJSONObject | null; - } = { - data: [], - error: null, - }; - - do { - const params: IJSONObject = { - query: searchTerm, - since_id: lastInternalId, - pagination_token: response?.data?.meta?.next_token, - }; - - const queryParams = qs.stringify(omitBy(params, isEmpty)); - - const requestPath = `/2/tweets/search/recent${ - queryParams.toString() ? `?${queryParams.toString()}` : '' - }`; - - const requestData = { - url: `${TwitterClient.baseUrl}${requestPath}`, - method: 'GET', - }; - - const authHeader = this.client.oauthClient.toHeader( - this.client.oauthClient.authorize(requestData, token) - ); - - response = await this.client.httpClient.get(requestPath, { - headers: { ...authHeader }, - }); - - if (response.integrationError) { - tweets.error = response.integrationError; - return tweets; - } - - if (response.data.meta.result_count > 0) { - response.data.data.forEach((tweet: IJSONObject) => { - if (!lastInternalId || Number(tweet.id) > Number(lastInternalId)) { - tweets.data.push(tweet); - } else { - return; - } - }); - } - } while (response.data.meta.next_token && lastInternalId); - - return tweets; - } -} diff --git a/packages/backend/src/apps/twitter/client/endpoints/verify-access-token.ts b/packages/backend/src/apps/twitter/client/endpoints/verify-access-token.ts deleted file mode 100644 index 7c51a52f..00000000 --- a/packages/backend/src/apps/twitter/client/endpoints/verify-access-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -import TwitterClient from '../index'; - -export default class VerifyAccessToken { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run() { - try { - return await this.client.httpClient.post( - `/oauth/access_token?oauth_verifier=${this.client.connection.formattedData.oauthVerifier}&oauth_token=${this.client.connection.formattedData.accessToken}`, - null - ); - } catch (error) { - throw new Error(error.response.data); - } - } -} diff --git a/packages/backend/src/apps/twitter/client/index.ts b/packages/backend/src/apps/twitter/client/index.ts deleted file mode 100644 index 4cb899b5..00000000 --- a/packages/backend/src/apps/twitter/client/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { IFlow, IStep, IConnection } from '@automatisch/types'; -import OAuth from 'oauth-1.0a'; -import crypto from 'crypto'; -import createHttpClient, { IHttpClient } from '../../../helpers/http-client'; -import OAuthRequestToken from './endpoints/oauth-request-token'; -import VerifyAccessToken from './endpoints/verify-access-token'; -import GetCurrentUser from './endpoints/get-current-user'; -import GetUserByUsername from './endpoints/get-user-by-username'; -import GetUserTweets from './endpoints/get-user-tweets'; -import CreateTweet from './endpoints/create-tweet'; -import SearchTweets from './endpoints/search-tweets'; -import GetUserFollowers from './endpoints/get-user-followers'; - -export default class TwitterClient { - flow: IFlow; - step: IStep; - connection: IConnection; - oauthClient: OAuth; - httpClient: IHttpClient; - - oauthRequestToken: OAuthRequestToken; - verifyAccessToken: VerifyAccessToken; - getCurrentUser: GetCurrentUser; - getUserByUsername: GetUserByUsername; - getUserTweets: GetUserTweets; - createTweet: CreateTweet; - searchTweets: SearchTweets; - getUserFollowers: GetUserFollowers; - - static baseUrl = 'https://api.twitter.com'; - - constructor(connection: IConnection, flow?: IFlow, step?: IStep) { - this.connection = connection; - this.flow = flow; - this.step = step; - - this.httpClient = createHttpClient({ baseURL: TwitterClient.baseUrl }); - - const consumerData = { - key: this.connection.formattedData.consumerKey as string, - secret: this.connection.formattedData.consumerSecret as string, - }; - - this.oauthClient = new OAuth({ - consumer: consumerData, - signature_method: 'HMAC-SHA1', - hash_function(base_string, key) { - return crypto - .createHmac('sha1', key) - .update(base_string) - .digest('base64'); - }, - }); - - this.oauthRequestToken = new OAuthRequestToken(this); - this.verifyAccessToken = new VerifyAccessToken(this); - this.getCurrentUser = new GetCurrentUser(this); - this.getUserByUsername = new GetUserByUsername(this); - this.getUserTweets = new GetUserTweets(this); - this.createTweet = new CreateTweet(this); - this.searchTweets = new SearchTweets(this); - this.getUserFollowers = new GetUserFollowers(this); - } -} diff --git a/packages/backend/src/apps/twitter/common/generate-request.ts b/packages/backend/src/apps/twitter/common/generate-request.ts new file mode 100644 index 00000000..8772b997 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/generate-request.ts @@ -0,0 +1,44 @@ +import { Token } from 'oauth-1.0a'; +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; +import oauthClient from './oauth-client'; +import { Method } from 'axios'; + +type IGenereateRequestOptons = { + requestPath: string; + method: string; + data?: IJSONObject; +}; + +const generateRequest = async ( + $: IGlobalVariable, + options: IGenereateRequestOptons +) => { + const { requestPath, method, data } = options; + + const token: Token = { + key: $.auth.data.accessToken as string, + secret: $.auth.data.accessSecret as string, + }; + + const requestData = { + url: `${$.app.baseUrl}${requestPath}`, + method, + data, + }; + + const authHeader = oauthClient($).toHeader( + oauthClient($).authorize(requestData, token) + ); + + const response = await $.http.request({ + url: requestData.url, + method: requestData.method as Method, + headers: { + ...authHeader, + }, + }); + + return response; +}; + +export default generateRequest; diff --git a/packages/backend/src/apps/twitter/common/get-current-user.ts b/packages/backend/src/apps/twitter/common/get-current-user.ts new file mode 100644 index 00000000..0823192f --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-current-user.ts @@ -0,0 +1,14 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; +import generateRequest from './generate-request'; + +const getCurrentUser = async ($: IGlobalVariable): Promise => { + const response = await generateRequest($, { + requestPath: '/2/users/me', + method: 'GET', + }); + + const currentUser = response.data.data; + return currentUser; +}; + +export default getCurrentUser; diff --git a/packages/backend/src/apps/twitter/common/get-user-by-username.ts b/packages/backend/src/apps/twitter/common/get-user-by-username.ts new file mode 100644 index 00000000..45e108be --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-user-by-username.ts @@ -0,0 +1,22 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; +import generateRequest from './generate-request'; + +const getUserByUsername = async ($: IGlobalVariable, username: string) => { + const response = await generateRequest($, { + requestPath: `/2/users/by/username/${username}`, + method: 'GET', + }); + + if (response.data.errors) { + const errorMessages = response.data.errors + .map((error: IJSONObject) => error.detail) + .join(' '); + + throw new Error(`Error occured while fetching user data: ${errorMessages}`); + } + + const user = response.data.data; + return user; +}; + +export default getUserByUsername; diff --git a/packages/backend/src/apps/twitter/common/get-user-followers.ts b/packages/backend/src/apps/twitter/common/get-user-followers.ts new file mode 100644 index 00000000..0d4e1e64 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-user-followers.ts @@ -0,0 +1,68 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; +import { URLSearchParams } from 'url'; +import { omitBy, isEmpty } from 'lodash'; +import generateRequest from './generate-request'; + +type GetUserFollowersOptions = { + userId: string; + lastInternalId?: string; +}; + +const getUserFollowers = async ( + $: IGlobalVariable, + options: GetUserFollowersOptions +) => { + let response; + + const followers: { + data: IJSONObject[]; + error: IJSONObject | null; + } = { + data: [], + error: null, + }; + + do { + const params: IJSONObject = { + pagination_token: response?.data?.meta?.next_token, + }; + + const queryParams = new URLSearchParams(omitBy(params, isEmpty)); + + const requestPath = `/2/users/${options.userId}/followers${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = await generateRequest($, { + requestPath, + method: 'GET', + }); + + if (response.integrationError) { + followers.error = response.integrationError; + return followers; + } + + if (response.data?.errors) { + followers.error = response.data.errors; + return followers; + } + + if (response.data.meta.result_count > 0) { + response.data.data.forEach((tweet: IJSONObject) => { + if ( + !options.lastInternalId || + Number(tweet.id) > Number(options.lastInternalId) + ) { + followers.data.push(tweet); + } else { + return; + } + }); + } + } while (response.data.meta.next_token && options.lastInternalId); + + return followers; +}; + +export default getUserFollowers; diff --git a/packages/backend/src/apps/twitter/common/get-user-tweets.ts b/packages/backend/src/apps/twitter/common/get-user-tweets.ts new file mode 100644 index 00000000..901d22a3 --- /dev/null +++ b/packages/backend/src/apps/twitter/common/get-user-tweets.ts @@ -0,0 +1,79 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; +import { URLSearchParams } from 'url'; +import omitBy from 'lodash/omitBy'; +import isEmpty from 'lodash/isEmpty'; +import generateRequest from './generate-request'; +import getCurrentUser from './get-current-user'; +import getUserByUsername from './get-user-by-username'; + +type IGetUserTweetsOptions = { + currentUser: boolean; + userId?: string; + lastInternalId?: string; +}; + +const getUserTweets = async ( + $: IGlobalVariable, + options: IGetUserTweetsOptions +) => { + let username: string; + + if (options.currentUser) { + const currentUser = await getCurrentUser($); + username = currentUser.username as string; + } else { + username = $.db.step.parameters.username as string; + } + + const user = await getUserByUsername($, username); + + let response; + + const tweets: { + data: IJSONObject[]; + error: IJSONObject | null; + } = { + data: [], + error: null, + }; + + do { + const params: IJSONObject = { + since_id: options.lastInternalId, + pagination_token: response?.data?.meta?.next_token, + }; + + const queryParams = new URLSearchParams(omitBy(params, isEmpty)); + + const requestPath = `/2/users/${user.id}/tweets${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = await generateRequest($, { + requestPath, + method: 'GET', + }); + + if (response.integrationError) { + tweets.error = response.integrationError; + return tweets; + } + + if (response.data.meta.result_count > 0) { + response.data.data.forEach((tweet: IJSONObject) => { + if ( + !options.lastInternalId || + Number(tweet.id) > Number(options.lastInternalId) + ) { + tweets.data.push(tweet); + } else { + return; + } + }); + } + } while (response.data.meta.next_token && options.lastInternalId); + + return tweets; +}; + +export default getUserTweets; diff --git a/packages/backend/src/apps/twitter/common/oauth-client.ts b/packages/backend/src/apps/twitter/common/oauth-client.ts new file mode 100644 index 00000000..c29f6a0a --- /dev/null +++ b/packages/backend/src/apps/twitter/common/oauth-client.ts @@ -0,0 +1,23 @@ +import { IGlobalVariable } from '@automatisch/types'; +import crypto from 'crypto'; +import OAuth from 'oauth-1.0a'; + +const oauthClient = ($: IGlobalVariable) => { + const consumerData = { + key: $.auth.data.consumerKey as string, + secret: $.auth.data.consumerSecret as string, + }; + + return new OAuth({ + consumer: consumerData, + signature_method: 'HMAC-SHA1', + hash_function(base_string, key) { + return crypto + .createHmac('sha1', key) + .update(base_string) + .digest('base64'); + }, + }); +}; + +export default oauthClient; diff --git a/packages/backend/src/apps/twitter/index.ts b/packages/backend/src/apps/twitter/index.ts index ee8e1005..0f0ef9a4 100644 --- a/packages/backend/src/apps/twitter/index.ts +++ b/packages/backend/src/apps/twitter/index.ts @@ -1,27 +1,8 @@ -import { - IService, - IAuthentication, - IFlow, - IStep, - IConnection, -} from '@automatisch/types'; -import Authentication from './authentication'; -import Triggers from './triggers'; -import Actions from './actions'; -import TwitterClient from './client'; - -export default class Twitter implements IService { - client: TwitterClient; - - authenticationClient: IAuthentication; - triggers: Triggers; - actions: Actions; - - constructor(connection: IConnection, flow?: IFlow, step?: IStep) { - this.client = new TwitterClient(connection, flow, step); - - this.authenticationClient = new Authentication(this.client); - this.triggers = new Triggers(this.client); - this.actions = new Actions(this.client); - } -} +export default { + name: 'Twitter', + key: 'twitter', + iconUrl: '{BASE_URL}/apps/twitter/assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/connections/twitter', + supportsConnections: true, + baseUrl: 'https://api.twitter.com', +}; diff --git a/packages/backend/src/apps/twitter/info.json b/packages/backend/src/apps/twitter/info.json deleted file mode 100644 index a9a4cd6c..00000000 --- a/packages/backend/src/apps/twitter/info.json +++ /dev/null @@ -1,338 +0,0 @@ -{ - "name": "Twitter", - "key": "twitter", - "iconUrl": "{BASE_URL}/apps/twitter/assets/favicon.svg", - "docUrl": "https://automatisch.io/docs/twitter", - "authDocUrl": "https://automatisch.io/docs/connections/twitter", - "primaryColor": "2DAAE1", - "supportsConnections": true, - "fields": [ - { - "key": "oAuthRedirectUrl", - "label": "OAuth Redirect URL", - "type": "string", - "required": true, - "readOnly": true, - "value": "{WEB_APP_URL}/app/twitter/connections/add", - "placeholder": null, - "description": "When asked to input an OAuth callback or redirect URL in Twitter OAuth, enter the URL above.", - "clickToCopy": true - }, - { - "key": "consumerKey", - "label": "API Key", - "type": "string", - "required": true, - "readOnly": false, - "value": null, - "placeholder": null, - "description": null, - "clickToCopy": false - }, - { - "key": "consumerSecret", - "label": "API Secret", - "type": "string", - "required": true, - "readOnly": false, - "value": null, - "placeholder": null, - "description": null, - "clickToCopy": false - } - ], - "authenticationSteps": [ - { - "step": 1, - "type": "mutation", - "name": "createConnection", - "arguments": [ - { - "name": "key", - "value": "{key}" - }, - { - "name": "formattedData", - "value": null, - "properties": [ - { - "name": "consumerKey", - "value": "{fields.consumerKey}" - }, - { - "name": "consumerSecret", - "value": "{fields.consumerSecret}" - } - ] - } - ] - }, - { - "step": 2, - "type": "mutation", - "name": "createAuthData", - "arguments": [ - { - "name": "id", - "value": "{createConnection.id}" - } - ] - }, - { - "step": 3, - "type": "openWithPopup", - "name": "openAuthPopup", - "arguments": [ - { - "name": "url", - "value": "{createAuthData.url}" - } - ] - }, - { - "step": 4, - "type": "mutation", - "name": "updateConnection", - "arguments": [ - { - "name": "id", - "value": "{createConnection.id}" - }, - { - "name": "formattedData", - "value": null, - "properties": [ - { - "name": "oauthVerifier", - "value": "{openAuthPopup.oauth_verifier}" - } - ] - } - ] - }, - { - "step": 5, - "type": "mutation", - "name": "verifyConnection", - "arguments": [ - { - "name": "id", - "value": "{createConnection.id}" - } - ] - } - ], - "reconnectionSteps": [ - { - "step": 1, - "type": "mutation", - "name": "resetConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - } - ] - }, - { - "step": 2, - "type": "mutation", - "name": "updateConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - }, - { - "name": "formattedData", - "value": null, - "properties": [ - { - "name": "consumerKey", - "value": "{fields.consumerKey}" - }, - { - "name": "consumerSecret", - "value": "{fields.consumerSecret}" - } - ] - } - ] - }, - { - "step": 3, - "type": "mutation", - "name": "createAuthData", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - } - ] - }, - { - "step": 4, - "type": "openWithPopup", - "name": "openAuthPopup", - "arguments": [ - { - "name": "url", - "value": "{createAuthData.url}" - } - ] - }, - { - "step": 5, - "type": "mutation", - "name": "updateConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - }, - { - "name": "formattedData", - "value": null, - "properties": [ - { - "name": "oauthVerifier", - "value": "{openAuthPopup.oauth_verifier}" - } - ] - } - ] - }, - { - "step": 6, - "type": "mutation", - "name": "verifyConnection", - "arguments": [ - { - "name": "id", - "value": "{connection.id}" - } - ] - } - ], - "triggers": [ - { - "name": "My Tweets", - "key": "myTweets", - "pollInterval": 15, - "description": "Will be triggered when you tweet something new.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - }, - { - "name": "User Tweets", - "key": "userTweets", - "pollInterval": 15, - "description": "Will be triggered when a specific user tweet something new.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Username", - "key": "username", - "type": "string", - "required": true - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - }, - { - "name": "Search Tweets", - "key": "searchTweets", - "pollInterval": 15, - "description": "Will be triggered when any user tweet something containing a specific keyword, phrase, username or hashtag.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "chooseTrigger", - "name": "Set up a trigger", - "arguments": [ - { - "label": "Search Term", - "key": "searchTerm", - "type": "string", - "required": true - } - ] - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - }, - { - "name": "New follower of me", - "key": "myFollowers", - "pollInterval": 15, - "description": "Will be triggered when you have a new follower.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "testStep", - "name": "Test trigger" - } - ] - } - ], - "actions": [ - { - "name": "Create Tweet", - "key": "createTweet", - "description": "Will create a tweet.", - "substeps": [ - { - "key": "chooseConnection", - "name": "Choose connection" - }, - { - "key": "chooseAction", - "name": "Set up action", - "arguments": [ - { - "label": "Tweet body", - "key": "tweet", - "type": "string", - "required": true, - "description": "The content of your new tweet.", - "variables": true - } - ] - }, - { - "key": "testStep", - "name": "Test action" - } - ] - } - ] -} diff --git a/packages/backend/src/apps/twitter/triggers.ts b/packages/backend/src/apps/twitter/triggers.ts deleted file mode 100644 index 2d611581..00000000 --- a/packages/backend/src/apps/twitter/triggers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import TwitterClient from './client'; -import UserTweets from './triggers/user-tweets'; -import SearchTweets from './triggers/search-tweets'; -import MyTweets from './triggers/my-tweets'; -import MyFollowers from './triggers/my-followers'; - -export default class Triggers { - client: TwitterClient; - userTweets: UserTweets; - searchTweets: SearchTweets; - myTweets: MyTweets; - myFollowers: MyFollowers; - - constructor(client: TwitterClient) { - this.client = client; - this.userTweets = new UserTweets(client); - this.searchTweets = new SearchTweets(client); - this.myTweets = new MyTweets(client); - this.myFollowers = new MyFollowers(client); - } -} diff --git a/packages/backend/src/apps/twitter/triggers/my-followers.ts b/packages/backend/src/apps/twitter/triggers/my-followers.ts deleted file mode 100644 index 8aaaba51..00000000 --- a/packages/backend/src/apps/twitter/triggers/my-followers.ts +++ /dev/null @@ -1,28 +0,0 @@ -import TwitterClient from '../client'; - -export default class MyFollowers { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(lastInternalId: string) { - return this.getFollowers(lastInternalId); - } - - async testRun() { - return this.getFollowers(); - } - - async getFollowers(lastInternalId?: string) { - const { username } = await this.client.getCurrentUser.run(); - const user = await this.client.getUserByUsername.run(username as string); - - const tweets = await this.client.getUserFollowers.run( - user.id, - lastInternalId - ); - return tweets; - } -} diff --git a/packages/backend/src/apps/twitter/triggers/my-tweets.ts b/packages/backend/src/apps/twitter/triggers/my-tweets.ts deleted file mode 100644 index dd88b69c..00000000 --- a/packages/backend/src/apps/twitter/triggers/my-tweets.ts +++ /dev/null @@ -1,25 +0,0 @@ -import TwitterClient from '../client'; - -export default class MyTweets { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(lastInternalId: string) { - return this.getTweets(lastInternalId); - } - - async testRun() { - return this.getTweets(); - } - - async getTweets(lastInternalId?: string) { - const { username } = await this.client.getCurrentUser.run(); - const user = await this.client.getUserByUsername.run(username as string); - - const tweets = await this.client.getUserTweets.run(user.id, lastInternalId); - return tweets; - } -} diff --git a/packages/backend/src/apps/twitter/triggers/my-tweets/index.ts b/packages/backend/src/apps/twitter/triggers/my-tweets/index.ts new file mode 100644 index 00000000..e6cb95a0 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/my-tweets/index.ts @@ -0,0 +1,30 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getUserTweets from '../../common/get-user-tweets'; + +export default { + name: 'My Tweets', + key: 'myTweets', + pollInterval: 15, + description: 'Will be triggered when you tweet something new.', + substeps: [ + { + key: 'chooseConnection', + name: 'Choose connection', + }, + { + key: 'testStep', + name: 'Test trigger', + }, + ], + + async run($: IGlobalVariable) { + return await getUserTweets($, { + currentUser: true, + lastInternalId: $.db.flow.lastInternalId, + }); + }, + + async testRun($: IGlobalVariable) { + return await getUserTweets($, { currentUser: true }); + }, +}; diff --git a/packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.ts b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.ts new file mode 100644 index 00000000..c9b4be84 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/index.ts @@ -0,0 +1,27 @@ +import { IGlobalVariable } from '@automatisch/types'; +import myFollowers from './my-followers'; + +export default { + name: 'New follower of me', + key: 'myFollowers', + pollInterval: 15, + description: 'Will be triggered when you have a new follower.', + substeps: [ + { + key: 'chooseConnection', + name: 'Choose connection', + }, + { + key: 'testStep', + name: 'Test trigger', + }, + ], + + async run($: IGlobalVariable) { + return await myFollowers($, $.db.flow.lastInternalId); + }, + + async testRun($: IGlobalVariable) { + return await myFollowers($); + }, +}; diff --git a/packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.ts b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.ts new file mode 100644 index 00000000..a4d0fa0e --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/new-follower-of-me/my-followers.ts @@ -0,0 +1,17 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getCurrentUser from '../../common/get-current-user'; +import getUserByUsername from '../../common/get-user-by-username'; +import getUserFollowers from '../../common/get-user-followers'; + +const myFollowers = async ($: IGlobalVariable, lastInternalId?: string) => { + const { username } = await getCurrentUser($); + const user = await getUserByUsername($, username as string); + + const tweets = await getUserFollowers($, { + userId: user.id, + lastInternalId, + }); + return tweets; +}; + +export default myFollowers; diff --git a/packages/backend/src/apps/twitter/triggers/search-tweets.ts b/packages/backend/src/apps/twitter/triggers/search-tweets.ts deleted file mode 100644 index 44ccb5df..00000000 --- a/packages/backend/src/apps/twitter/triggers/search-tweets.ts +++ /dev/null @@ -1,26 +0,0 @@ -import TwitterClient from '../client'; - -export default class SearchTweets { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(lastInternalId: string) { - return this.getTweets(lastInternalId); - } - - async testRun() { - return this.getTweets(); - } - - async getTweets(lastInternalId?: string) { - const tweets = await this.client.searchTweets.run( - this.client.step.parameters.searchTerm as string, - lastInternalId - ); - - return tweets; - } -} diff --git a/packages/backend/src/apps/twitter/triggers/search-tweets/index.ts b/packages/backend/src/apps/twitter/triggers/search-tweets/index.ts new file mode 100644 index 00000000..0f67d908 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/search-tweets/index.ts @@ -0,0 +1,45 @@ +import { IGlobalVariable } from '@automatisch/types'; +import searchTweets from './search-tweets'; + +export default { + name: 'Search Tweets', + key: 'searchTweets', + pollInterval: 15, + description: + 'Will be triggered when any user tweet something containing a specific keyword, phrase, username or hashtag.', + substeps: [ + { + key: 'chooseConnection', + name: 'Choose connection', + }, + { + key: 'chooseTrigger', + name: 'Set up a trigger', + arguments: [ + { + label: 'Search Term', + key: 'searchTerm', + type: 'string', + required: true, + }, + ], + }, + { + key: 'testStep', + name: 'Test trigger', + }, + ], + + async run($: IGlobalVariable) { + return await searchTweets($, { + searchTerm: $.db.step.parameters.searchTerm as string, + lastInternalId: $.db.flow.lastInternalId, + }); + }, + + async testRun($: IGlobalVariable) { + return await searchTweets($, { + searchTerm: $.db.step.parameters.searchTerm as string, + }); + }, +}; diff --git a/packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.ts b/packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.ts new file mode 100644 index 00000000..8e3886c6 --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/search-tweets/search-tweets.ts @@ -0,0 +1,70 @@ +import { IGlobalVariable, IJSONObject } from '@automatisch/types'; +import qs from 'qs'; +import generateRequest from '../../common/generate-request'; +import { omitBy, isEmpty } from 'lodash'; + +type ISearchTweetsOptions = { + searchTerm: string; + lastInternalId?: string; +}; + +const searchTweets = async ( + $: IGlobalVariable, + options: ISearchTweetsOptions +) => { + let response; + + const tweets: { + data: IJSONObject[]; + error: IJSONObject | null; + } = { + data: [], + error: null, + }; + + do { + const params: IJSONObject = { + query: options.searchTerm, + since_id: options.lastInternalId, + pagination_token: response?.data?.meta?.next_token, + }; + + const queryParams = qs.stringify(omitBy(params, isEmpty)); + + const requestPath = `/2/tweets/search/recent${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + response = await generateRequest($, { + requestPath, + method: 'GET', + }); + + if (response.integrationError) { + tweets.error = response.integrationError; + return tweets; + } + + if (response.data.errors) { + tweets.error = response.data.errors; + return tweets; + } + + if (response.data.meta.result_count > 0) { + response.data.data.forEach((tweet: IJSONObject) => { + if ( + !options.lastInternalId || + Number(tweet.id) > Number(options.lastInternalId) + ) { + tweets.data.push(tweet); + } else { + return; + } + }); + } + } while (response.data.meta.next_token && options.lastInternalId); + + return tweets; +}; + +export default searchTweets; diff --git a/packages/backend/src/apps/twitter/triggers/user-tweets.ts b/packages/backend/src/apps/twitter/triggers/user-tweets.ts deleted file mode 100644 index bd4919b0..00000000 --- a/packages/backend/src/apps/twitter/triggers/user-tweets.ts +++ /dev/null @@ -1,27 +0,0 @@ -import TwitterClient from '../client'; - -export default class UserTweets { - client: TwitterClient; - - constructor(client: TwitterClient) { - this.client = client; - } - - async run(lastInternalId: string) { - return this.getTweets(lastInternalId); - } - - async testRun() { - return this.getTweets(); - } - - async getTweets(lastInternalId?: string) { - const user = await this.client.getUserByUsername.run( - this.client.step.parameters.username as string - ); - - const tweets = await this.client.getUserTweets.run(user.id, lastInternalId); - - return tweets; - } -} diff --git a/packages/backend/src/apps/twitter/triggers/user-tweets/index.ts b/packages/backend/src/apps/twitter/triggers/user-tweets/index.ts new file mode 100644 index 00000000..23517c9e --- /dev/null +++ b/packages/backend/src/apps/twitter/triggers/user-tweets/index.ts @@ -0,0 +1,46 @@ +import { IGlobalVariable } from '@automatisch/types'; +import getUserTweets from '../../common/get-user-tweets'; + +export default { + name: 'User Tweets', + key: 'userTweets', + pollInterval: 15, + description: 'Will be triggered when a specific user tweet something new.', + substeps: [ + { + key: 'chooseConnection', + name: 'Choose connection', + }, + { + key: 'chooseTrigger', + name: 'Set up a trigger', + arguments: [ + { + label: 'Username', + key: 'username', + type: 'string', + required: true, + }, + ], + }, + { + key: 'testStep', + name: 'Test trigger', + }, + ], + + async run($: IGlobalVariable) { + return await getUserTweets($, { + currentUser: false, + userId: $.db.step.parameters.username as string, + lastInternalId: $.db.flow.lastInternalId, + }); + }, + + async testRun($: IGlobalVariable) { + return await getUserTweets($, { + currentUser: false, + userId: $.db.step.parameters.username as string, + }); + }, +}; diff --git a/packages/backend/src/graphql/mutations/create-auth-data.ts b/packages/backend/src/graphql/mutations/create-auth-data.ts index 8a4b8886..91ff8d60 100644 --- a/packages/backend/src/graphql/mutations/create-auth-data.ts +++ b/packages/backend/src/graphql/mutations/create-auth-data.ts @@ -1,5 +1,7 @@ import Context from '../../types/express/context'; import axios from 'axios'; +import globalVariable from '../../helpers/global-variable'; +import App from '../../models/app'; type Params = { input: { @@ -19,29 +21,24 @@ const createAuthData = async ( }) .throwIfNotFound(); - const appClass = (await import(`../../apps/${connection.key}`)).default; - if (!connection.formattedData) { return null; } - const appInstance = new appClass(connection); - const authLink = await appInstance.authenticationClient.createAuthData(); + const authInstance = (await import(`../../apps/${connection.key}/auth`)) + .default; + const app = await App.findOneByKey(connection.key); + + const $ = await globalVariable(connection, app); + await authInstance.createAuthData($); try { - await axios.get(authLink.url); + await axios.get(connection.formattedData.url as string); } catch (error) { throw new Error('Error occured while creating authorization URL!'); } - await connection.$query().patch({ - formattedData: { - ...connection.formattedData, - ...authLink, - }, - }); - - return authLink; + return connection.formattedData; }; export default createAuthData; diff --git a/packages/backend/src/graphql/mutations/create-connection.ts b/packages/backend/src/graphql/mutations/create-connection.ts index ac84cd74..fcaf093d 100644 --- a/packages/backend/src/graphql/mutations/create-connection.ts +++ b/packages/backend/src/graphql/mutations/create-connection.ts @@ -13,7 +13,7 @@ const createConnection = async ( params: Params, context: Context ) => { - App.findOneByKey(params.input.key); + await App.findOneByKey(params.input.key); return await context.currentUser.$relatedQuery('connections').insert({ key: params.input.key, diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index 11ec676e..5e0e0297 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -33,7 +33,7 @@ const updateFlowStatus = async ( const triggerStep = await flow.getTriggerStep(); const trigger = await triggerStep.getTrigger(); - const interval = trigger.interval; + const interval = trigger?.getInterval(triggerStep.parameters); const repeatOptions = { cron: interval || EVERY_15_MINUTES_CRON, }; diff --git a/packages/backend/src/graphql/mutations/update-step.ts b/packages/backend/src/graphql/mutations/update-step.ts index 45fd1295..0c5adbed 100644 --- a/packages/backend/src/graphql/mutations/update-step.ts +++ b/packages/backend/src/graphql/mutations/update-step.ts @@ -1,3 +1,4 @@ +import { IJSONObject } from '@automatisch/types'; import Step from '../../models/step'; import Context from '../../types/express/context'; @@ -6,7 +7,7 @@ type Params = { id: string; key: string; appKey: string; - parameters: Record; + parameters: IJSONObject; flow: { id: string; }; diff --git a/packages/backend/src/graphql/mutations/verify-connection.ts b/packages/backend/src/graphql/mutations/verify-connection.ts index b0863aa5..081752b9 100644 --- a/packages/backend/src/graphql/mutations/verify-connection.ts +++ b/packages/backend/src/graphql/mutations/verify-connection.ts @@ -1,5 +1,6 @@ import Context from '../../types/express/context'; import App from '../../models/app'; +import globalVariable from '../../helpers/global-variable'; type Params = { input: { @@ -19,18 +20,11 @@ const verifyConnection = async ( }) .throwIfNotFound(); - const appClass = (await import(`../../apps/${connection.key}`)).default; - const app = App.findOneByKey(connection.key); - - const appInstance = new appClass(connection); - const verifiedCredentials = - await appInstance.authenticationClient.verifyCredentials(); + const app = await App.findOneByKey(connection.key); + const $ = await globalVariable(connection, app); + await app.auth.verifyCredentials($); connection = await connection.$query().patchAndFetch({ - formattedData: { - ...connection.formattedData, - ...verifiedCredentials, - }, verified: true, draft: false, }); diff --git a/packages/backend/src/graphql/queries/get-app.ts b/packages/backend/src/graphql/queries/get-app.ts index 32479096..83f116d9 100644 --- a/packages/backend/src/graphql/queries/get-app.ts +++ b/packages/backend/src/graphql/queries/get-app.ts @@ -6,7 +6,7 @@ type Params = { }; const getApp = async (_parent: unknown, params: Params, context: Context) => { - const app = App.findOneByKey(params.key); + const app = await App.findOneByKey(params.key); if (context.currentUser) { const connections = await context.currentUser diff --git a/packages/backend/src/graphql/queries/get-apps.ts b/packages/backend/src/graphql/queries/get-apps.ts index 351fc394..d0928b44 100644 --- a/packages/backend/src/graphql/queries/get-apps.ts +++ b/packages/backend/src/graphql/queries/get-apps.ts @@ -6,8 +6,8 @@ type Params = { onlyWithTriggers: boolean; }; -const getApps = (_parent: unknown, params: Params) => { - const apps = App.findAll(params.name); +const getApps = async (_parent: unknown, params: Params) => { + const apps = await App.findAll(params.name); if (params.onlyWithTriggers) { return apps.filter((app: IApp) => app.triggers?.length); diff --git a/packages/backend/src/graphql/queries/get-connected-apps.ts b/packages/backend/src/graphql/queries/get-connected-apps.ts index ecf18847..a988e60c 100644 --- a/packages/backend/src/graphql/queries/get-connected-apps.ts +++ b/packages/backend/src/graphql/queries/get-connected-apps.ts @@ -11,7 +11,7 @@ const getConnectedApps = async ( params: Params, context: Context ) => { - let apps = App.findAll(params.name); + let apps = await App.findAll(params.name); const connections = await context.currentUser .$relatedQuery('connections') diff --git a/packages/backend/src/graphql/queries/get-data.ts b/packages/backend/src/graphql/queries/get-data.ts index f4fd4959..5b158869 100644 --- a/packages/backend/src/graphql/queries/get-data.ts +++ b/packages/backend/src/graphql/queries/get-data.ts @@ -1,5 +1,7 @@ -import { IJSONObject } from '@automatisch/types'; +import { IData, IJSONObject } from '@automatisch/types'; import Context from '../../types/express/context'; +import App from '../../models/app'; +import globalVariable from '../../helpers/global-variable'; type Params = { stepId: string; @@ -22,13 +24,18 @@ const getData = async (_parent: unknown, params: Params, context: Context) => { if (!connection || !step.appKey) return null; - const AppClass = (await import(`../../apps/${step.appKey}`)).default; - const appInstance = new AppClass(connection, step.flow, step); + const app = await App.findOneByKey(step.appKey); + const $ = await globalVariable(connection, app, step.flow, step); - const command = appInstance.data[params.key]; - const fetchedData = await command.run(); + const command = app.data.find((data: IData) => data.key === params.key); - return fetchedData; + const fetchedData = await command.run($); + + if (fetchedData.error) { + throw new Error(JSON.stringify(fetchedData.error)); + } + + return fetchedData.data; }; export default getData; diff --git a/packages/backend/src/graphql/queries/test-connection.ts b/packages/backend/src/graphql/queries/test-connection.ts index 1b9a01f5..5aa90fd1 100644 --- a/packages/backend/src/graphql/queries/test-connection.ts +++ b/packages/backend/src/graphql/queries/test-connection.ts @@ -1,4 +1,6 @@ import Context from '../../types/express/context'; +import App from '../../models/app'; +import globalVariable from '../../helpers/global-variable'; type Params = { id: string; @@ -17,11 +19,11 @@ const testConnection = async ( }) .throwIfNotFound(); - const appClass = (await import(`../../apps/${connection.key}`)).default; - const appInstance = new appClass(connection); + const app = await App.findOneByKey(connection.key, false); + const $ = await globalVariable(connection, app); const isStillVerified = - await appInstance.authenticationClient.isStillVerified(); + await app.auth.isStillVerified($); connection = await connection.$query().patchAndFetch({ formattedData: connection.formattedData, diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index a6f97dc9..689f660c 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -104,14 +104,18 @@ type App { authDocUrl: String primaryColor: String supportsConnections: Boolean - fields: [Field] - authenticationSteps: [AuthenticationStep] - reconnectionSteps: [ReconnectionStep] + auth: AppAuth triggers: [Trigger] actions: [Action] connections: [Connection] } +type AppAuth { + fields: [Field] + authenticationSteps: [AuthenticationStep] + reconnectionSteps: [ReconnectionStep] +} + enum ArgumentEnumType { integer string diff --git a/packages/backend/src/helpers/app-assets-handler.ts b/packages/backend/src/helpers/app-assets-handler.ts index dd85808b..e537b7c1 100644 --- a/packages/backend/src/helpers/app-assets-handler.ts +++ b/packages/backend/src/helpers/app-assets-handler.ts @@ -1,11 +1,9 @@ import express, { Application } from 'express'; -import App from '../models/app'; const appAssetsHandler = async (app: Application) => { - const appNames = App.list; - - appNames.forEach(appName => { - const svgPath = `${__dirname}/../apps/${appName}/assets/favicon.svg`; + app.use('/apps/:appKey/assets/favicon.svg', (req, res, next) => { + const { appKey } = req.params; + const svgPath = `${__dirname}/../apps/${appKey}/assets/favicon.svg`; const staticFileHandlerOptions = { /** * Disabling fallthrough is important to respond with HTTP 404. @@ -15,11 +13,8 @@ const appAssetsHandler = async (app: Application) => { }; const staticFileHandler = express.static(svgPath, staticFileHandlerOptions); - app.use( - `/apps/${appName}/assets/favicon.svg`, - staticFileHandler - ) - }) -} + return staticFileHandler(req, res, next); + }); +}; export default appAssetsHandler; diff --git a/packages/backend/src/helpers/app-info-converter.ts b/packages/backend/src/helpers/app-info-converter.ts index df736006..8ea38c2a 100644 --- a/packages/backend/src/helpers/app-info-converter.ts +++ b/packages/backend/src/helpers/app-info-converter.ts @@ -1,12 +1,22 @@ import type { IApp } from '@automatisch/types'; import appConfig from '../config/app'; -const appInfoConverter = (rawAppData: string) => { - let computedRawData = rawAppData.replace('{BASE_URL}', appConfig.baseUrl); - computedRawData = computedRawData.replace('{WEB_APP_URL}', appConfig.webAppUrl); +const appInfoConverter = (rawAppData: IApp) => { + rawAppData.iconUrl = rawAppData.iconUrl.replace( + '{BASE_URL}', + appConfig.baseUrl + ); - const computedJSONData: IApp = JSON.parse(computedRawData) - return computedJSONData; -} + if (rawAppData.auth?.fields) { + rawAppData.auth.fields = rawAppData.auth.fields.map((field) => { + return { + ...field, + value: field.value?.replace('{WEB_APP_URL}', appConfig.webAppUrl), + }; + }); + } + + return rawAppData; +}; export default appInfoConverter; diff --git a/packages/backend/src/helpers/get-app.ts b/packages/backend/src/helpers/get-app.ts new file mode 100644 index 00000000..75913c45 --- /dev/null +++ b/packages/backend/src/helpers/get-app.ts @@ -0,0 +1,78 @@ +import fs from 'fs'; +import { join } from 'path'; +import { IApp, IAuth, IAction, ITrigger, IData } from '@automatisch/types'; + +const appsPath = join(__dirname, '../apps'); + +async function getDefaultExport(path: string) { + return (await import(path)).default; +} + +function stripFunctions(data: C): C { + return JSON.parse(JSON.stringify(data)); +} + +async function getFileContent( + path: string, + stripFuncs: boolean +): Promise { + try { + const fileContent = await getDefaultExport(path); + + if (stripFuncs) { + return stripFunctions(fileContent); + } + + return fileContent; + } catch (err) { + return null; + } +} + +async function getChildrenContentInDirectory( + path: string, + stripFuncs: boolean +): Promise { + const appSubdirectory = join(appsPath, path); + const childrenContent = []; + + if (fs.existsSync(appSubdirectory)) { + const filesInSubdirectory = fs.readdirSync(appSubdirectory); + + for (const filename of filesInSubdirectory) { + const filePath = join(appSubdirectory, filename, 'index.ts'); + const fileContent = await getFileContent(filePath, stripFuncs); + + childrenContent.push(fileContent); + } + + return childrenContent; + } + + return []; +} + +const getApp = async (appKey: string, stripFuncs = true) => { + const appData: IApp = await getDefaultExport(`../apps/${appKey}`); + + appData.auth = await getFileContent( + `../apps/${appKey}/auth/index.ts`, + stripFuncs + ); + appData.triggers = await getChildrenContentInDirectory( + `${appKey}/triggers`, + stripFuncs + ); + appData.actions = await getChildrenContentInDirectory( + `${appKey}/actions`, + stripFuncs + ); + appData.data = await getChildrenContentInDirectory( + `${appKey}/data`, + stripFuncs + ); + + return appData; +}; + +export default getApp; diff --git a/packages/backend/src/helpers/global-variable.ts b/packages/backend/src/helpers/global-variable.ts new file mode 100644 index 00000000..e064a8cb --- /dev/null +++ b/packages/backend/src/helpers/global-variable.ts @@ -0,0 +1,42 @@ +import createHttpClient from './http-client'; +import Connection from '../models/connection'; +import Flow from '../models/flow'; +import Step from '../models/step'; +import { IJSONObject, IApp, IGlobalVariable } from '@automatisch/types'; + +const globalVariable = async ( + connection: Connection, + appData: IApp, + flow?: Flow, + currentStep?: Step +): Promise => { + const lastInternalId = await flow?.lastInternalId(); + + return { + auth: { + set: async (args: IJSONObject) => { + await connection.$query().patchAndFetch({ + formattedData: { + ...connection.formattedData, + ...args, + }, + }); + + return null; + }, + data: connection.formattedData, + }, + app: appData, + http: createHttpClient({ baseURL: appData.baseUrl }), + db: { + flow: { + lastInternalId, + }, + step: { + parameters: currentStep?.parameters || {}, + }, + }, + }; +}; + +export default globalVariable; diff --git a/packages/backend/src/models/app.ts b/packages/backend/src/models/app.ts index 62151633..2a52c810 100644 --- a/packages/backend/src/models/app.ts +++ b/packages/backend/src/models/app.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import { join } from 'path'; import { IApp } from '@automatisch/types'; import appInfoConverter from '../helpers/app-info-converter'; +import getApp from '../helpers/get-app'; class App { static folderPath = join(__dirname, '../apps'); @@ -11,28 +12,30 @@ class App { // their actions/triggers are implemented! static temporaryList = ['slack', 'twitter', 'scheduler']; - static findAll(name?: string): IApp[] { + static async findAll(name?: string, stripFuncs = true): Promise { if (!name) - return this.temporaryList.map((name) => this.findOneByName(name)); + return Promise.all( + this.temporaryList.map( + async (name) => await this.findOneByName(name, stripFuncs) + ) + ); - return this.temporaryList - .filter((app) => app.includes(name.toLowerCase())) - .map((name) => this.findOneByName(name)); + return Promise.all( + this.temporaryList + .filter((app) => app.includes(name.toLowerCase())) + .map((name) => this.findOneByName(name, stripFuncs)) + ); } - static findOneByName(name: string): IApp { - const rawAppData = fs.readFileSync( - this.folderPath + `/${name}/info.json`, - 'utf-8' - ); + static async findOneByName(name: string, stripFuncs = false): Promise { + const rawAppData = await getApp(name.toLocaleLowerCase(), stripFuncs); + return appInfoConverter(rawAppData); } - static findOneByKey(key: string): IApp { - const rawAppData = fs.readFileSync( - this.folderPath + `/${key}/info.json`, - 'utf-8' - ); + static async findOneByKey(key: string, stripFuncs = false): Promise { + const rawAppData = await getApp(key, stripFuncs); + return appInfoConverter(rawAppData); } } diff --git a/packages/backend/src/models/connection.ts b/packages/backend/src/models/connection.ts index 8d44fd07..34968a19 100644 --- a/packages/backend/src/models/connection.ts +++ b/packages/backend/src/models/connection.ts @@ -12,7 +12,7 @@ import Telemetry from '../helpers/telemetry'; class Connection extends Base { id!: string; key!: string; - data = ''; + data: string; formattedData?: IJSONObject; userId!: string; verified = false; @@ -56,10 +56,6 @@ class Connection extends Base { }, }); - get appData() { - return App.findOneByKey(this.key); - } - encryptData(): void { if (!this.eligibleForEncryption()) return; diff --git a/packages/backend/src/models/execution-step.ts b/packages/backend/src/models/execution-step.ts index 2f4c45ee..2d01e7fb 100644 --- a/packages/backend/src/models/execution-step.ts +++ b/packages/backend/src/models/execution-step.ts @@ -3,14 +3,15 @@ import Base from './base'; import Execution from './execution'; import Step from './step'; import Telemetry from '../helpers/telemetry'; +import { IJSONObject } from '@automatisch/types'; class ExecutionStep extends Base { id!: string; executionId!: string; stepId!: string; - dataIn!: Record; - dataOut!: Record; - errorDetails: Record; + dataIn!: IJSONObject; + dataOut!: IJSONObject; + errorDetails: IJSONObject; status = 'failure'; step: Step; @@ -23,7 +24,7 @@ class ExecutionStep extends Base { id: { type: 'string', format: 'uuid' }, executionId: { type: 'string', format: 'uuid' }, stepId: { type: 'string' }, - dataIn: { type: 'object' }, + dataIn: { type: ['object', 'null'] }, dataOut: { type: ['object', 'null'] }, status: { type: 'string', enum: ['success', 'failure'] }, errorDetails: { type: ['object', 'null'] }, diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 388c477c..db536431 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -10,7 +10,7 @@ class Flow extends Base { name!: string; userId!: string; active: boolean; - steps?: [Step]; + steps: Step[]; published_at: string; static tableName = 'flows'; diff --git a/packages/backend/src/models/step.ts b/packages/backend/src/models/step.ts index 61579f40..b69f3d20 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -4,7 +4,7 @@ import App from './app'; import Flow from './flow'; import Connection from './connection'; import ExecutionStep from './execution-step'; -import type { IStep } from '@automatisch/types'; +import type { IJSONObject, IStep } from '@automatisch/types'; import Telemetry from '../helpers/telemetry'; import appConfig from '../config/app'; @@ -17,10 +17,10 @@ class Step extends Base { connectionId?: string; status = 'incomplete'; position!: number; - parameters: Record; + parameters: IJSONObject; connection?: Connection; flow: Flow; - executionSteps?: [ExecutionStep]; + executionSteps: ExecutionStep[]; static tableName = 'steps'; @@ -78,10 +78,6 @@ class Step extends Base { return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`; } - get appData() { - return App.findOneByKey(this.appKey); - } - async $afterInsert(queryContext: QueryContext) { await super.$afterInsert(queryContext); Telemetry.stepCreated(this); @@ -101,12 +97,8 @@ class Step extends Base { const { appKey, key } = this; - const connection = await this.$relatedQuery('connection'); - const flow = await this.$relatedQuery('flow'); - - const AppClass = (await import(`../apps/${appKey}`)).default; - const appInstance = new AppClass(connection, flow, this); - const command = appInstance.triggers[key]; + const app = await App.findOneByKey(appKey); + const command = app.triggers.find((trigger) => trigger.key === key); return command; } diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index f753b889..35600ba1 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -10,9 +10,9 @@ class User extends Base { id!: string; email!: string; password!: string; - connections?: [Connection]; - flows?: [Flow]; - steps?: [Step]; + connections?: Connection[]; + flows?: Flow[]; + steps?: Step[]; static tableName = 'users'; diff --git a/packages/backend/src/services/processor.ts b/packages/backend/src/services/processor.ts index 21d58db6..224fcceb 100644 --- a/packages/backend/src/services/processor.ts +++ b/packages/backend/src/services/processor.ts @@ -1,9 +1,12 @@ import get from 'lodash.get'; +import { IJSONObject } from '@automatisch/types'; + +import App from '../models/app'; import Flow from '../models/flow'; import Step from '../models/step'; import Execution from '../models/execution'; import ExecutionStep from '../models/execution-step'; -import { IJSONObject } from '@automatisch/types'; +import globalVariable from '../helpers/global-variable'; type ExecutionSteps = Record; @@ -70,7 +73,7 @@ class Processor { const execution = await Execution.query().insert({ flowId: this.flow.id, testRun: this.testRun, - internalId: data.id, + internalId: data.id as string, }); executions.push(execution); @@ -92,7 +95,7 @@ class Processor { const { appKey, key, type, parameters: rawParameters = {}, id } = step; const isTrigger = type === 'trigger'; - const AppClass = (await import(`../apps/${appKey}`)).default; + const app = await App.findOneByKey(appKey); const computedParameters = Processor.computeParameters( rawParameters, @@ -101,19 +104,24 @@ class Processor { step.parameters = computedParameters; - const appInstance = new AppClass(step.connection, this.flow, step); + const $ = await globalVariable( + step.connection, + app, + this.flow, + step, + ); if (!isTrigger && key) { - const command = appInstance.actions[key]; - fetchedActionData = await command.run(); + const command = app.actions.find((action) => action.key === key); + fetchedActionData = await command.run($); } if (!isTrigger && fetchedActionData.error) { await execution.$relatedQuery('executionSteps').insertAndFetch({ stepId: id, status: 'failure', - dataIn: computedParameters, - dataOut: null, + dataIn: null, + dataOut: computedParameters, errorDetails: fetchedActionData.error, }); @@ -166,19 +174,22 @@ class Processor { async getInitialTriggerData(step: Step) { if (!step.appKey || !step.key) return null; - const AppClass = (await import(`../apps/${step.appKey}`)).default; - const appInstance = new AppClass(step.connection, this.flow, step); + const app = await App.findOneByKey(step.appKey); + const $ = await globalVariable( + step.connection, + app, + this.flow, + step, + ) - const command = appInstance.triggers[step.key]; + const command = app.triggers.find((trigger) => trigger.key === step.key); let fetchedData; - const lastInternalId = await this.flow.lastInternalId(); - if (this.testRun) { - fetchedData = await command.testRun(); + fetchedData = await command.testRun($); } else { - fetchedData = await command.run(lastInternalId); + fetchedData = await command.run($); } return fetchedData; diff --git a/packages/backend/src/types/axios.d.ts b/packages/backend/src/types/axios.d.ts deleted file mode 100644 index 948f46b4..00000000 --- a/packages/backend/src/types/axios.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IJSONObject } from '@automatisch/types'; - -declare module 'axios' { - interface AxiosResponse { - integrationError?: IJSONObject; - } -} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 3888c0b7..0f5693bd 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -9,23 +9,26 @@ "noImplicitAny": true, "outDir": "dist", "paths": { - "*": [ - "../../node_modules/*", - "node_modules/*", - "src/types/*" - ] + "*": ["../../node_modules/*", "node_modules/*", "src/types/*"] }, "skipLibCheck": true, "sourceMap": true, "target": "es2021", "typeRoots": [ "node_modules/@types", + "node_modules/@automatisch/types", "./src/types", "./src/apps" ] }, - "include": [ - "src/**/*", - "bin/**/*" + "include": ["src/**/*", "bin/**/*"], + "exclude": [ + "src/apps/discord", + "src/apps/firebase", + "src/apps/flickr", + "src/apps/github", + "src/apps/gitlab", + "src/apps/twitch", + "src/apps/typeform" ] } diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 49ec37c4..43898860 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -1,3 +1,6 @@ +import type { AxiosInstance } from 'axios'; +export type IHttpClient = AxiosInstance; + // Type definitions for automatisch export type IJSONValue = string | number | boolean | IJSONObject | IJSONArray; @@ -10,11 +13,11 @@ export interface IConnection { id: string; key: string; data: string; - formattedData: IJSONObject; + formattedData?: IJSONObject; userId: string; verified: boolean; - count: number; - flowCount: number; + count?: number; + flowCount?: number; appData?: IApp; createdAt: string; } @@ -22,7 +25,7 @@ export interface IConnection { export interface IExecutionStep { id: string; executionId: string; - stepId: IStep["id"]; + stepId: IStep['id']; step: IStep; dataIn: IJSONObject; dataOut: IJSONObject; @@ -44,21 +47,21 @@ export interface IExecution { export interface IStep { id: string; - name: string; + name?: string; flowId: string; - key: string; - appKey: string; + key?: string; + appKey?: string; iconUrl: string; type: 'action' | 'trigger'; - connectionId: string; + connectionId?: string; status: string; position: number; - parameters: Record; - connection: Partial; + parameters: IJSONObject; + connection?: Partial; flow: IFlow; executionSteps: IExecutionStep[]; // FIXME: remove this property once execution steps are properly exposed via queries - output: IJSONObject; + output?: IJSONObject; appData?: IApp; } @@ -128,7 +131,7 @@ export interface IFieldText { dependsOn: string[]; } -type IField = IFieldDropdown | IFieldText; +export type IField = IFieldDropdown | IFieldText; export interface IAuthenticationStepField { name: string; @@ -154,14 +157,27 @@ export interface IApp { authDocUrl: string; primaryColor: string; supportsConnections: boolean; + baseUrl: string; + auth: IAuth; + connectionCount: number; + flowCount: number; + data: IData; + triggers: ITrigger[]; + actions: IAction[]; + connections: IConnection[]; +} + +export interface IData { + [index: string]: any; +} + +export interface IAuth { + createAuthData($: IGlobalVariable): Promise, + verifyCredentials($: IGlobalVariable): Promise, + isStillVerified($: IGlobalVariable): Promise, fields: IField[]; authenticationSteps: IAuthenticationStep[]; reconnectionSteps: IAuthenticationStep[]; - connectionCount: number; - flowCount: number; - triggers: any[]; - actions: any[]; - connections: IConnection[]; } export interface IService { @@ -172,8 +188,22 @@ export interface IService { } export interface ITrigger { - run(startTime?: Date): Promise; - testRun(startTime?: Date): Promise; + name: string; + key: string, + pollInterval: number; + description: string; + substeps: ISubstep[]; + getInterval(parameters: IGlobalVariable["db"]["step"]["parameters"]): string; + run($: IGlobalVariable): Promise<{ data: IJSONObject[], error: IJSONObject | null }>; + testRun($: IGlobalVariable, startTime?: Date): Promise<{ data: IJSONObject[], error: IJSONObject | null }>; +} + +export interface IAction { + name: string; + key: string, + description: string; + substeps: ISubstep[]; + run($: IGlobalVariable): Promise<{ data: IJSONObject, error: IJSONObject | null }>; } export interface IAuthentication { @@ -183,10 +213,34 @@ export interface IAuthentication { } export interface ISubstep { + key: string; name: string; arguments: IField[]; } export type IHttpClientParams = { baseURL?: string; +}; + +export type IGlobalVariable = { + auth: { + set: (args: IJSONObject) => Promise; + data: IJSONObject; + }; + app: IApp; + http: IHttpClient; + db: { + flow: { + lastInternalId: string; + }; + step: { + parameters: IJSONObject; + } + }; +}; + +declare module 'axios' { + interface AxiosResponse { + integrationError?: IJSONObject; + } } diff --git a/packages/web/src/components/AddAppConnection/index.tsx b/packages/web/src/components/AddAppConnection/index.tsx index ddbf5359..3759514b 100644 --- a/packages/web/src/components/AddAppConnection/index.tsx +++ b/packages/web/src/components/AddAppConnection/index.tsx @@ -30,7 +30,8 @@ type Response = { export default function AddAppConnection(props: AddAppConnectionProps): React.ReactElement { const { application, connectionId, onClose } = props; - const { name, authDocUrl, key, fields, authenticationSteps, reconnectionSteps } = application; + const { name, authDocUrl, key, auth } = application; + const { fields, authenticationSteps, reconnectionSteps } = auth; const formatMessage = useFormatMessage(); const [errorMessage, setErrorMessage] = React.useState(null); const [inProgress, setInProgress] = React.useState(false); diff --git a/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx b/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx index e54d1d3b..396d4b78 100644 --- a/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx +++ b/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx @@ -12,10 +12,10 @@ import useFormatMessage from 'hooks/useFormatMessage'; import { EditorContext } from 'contexts/Editor'; import { GET_APPS } from 'graphql/queries/get-apps'; import FlowSubstepTitle from 'components/FlowSubstepTitle'; -import type { IApp, IStep, ISubstep } from '@automatisch/types'; +import type { IApp, IStep, ISubstep, ITrigger, IAction } from '@automatisch/types'; type ChooseAppAndEventSubstepProps = { - substep: ISubstep, + substep: ISubstep; expanded?: boolean; onExpand: () => void; onCollapse: () => void; @@ -24,14 +24,17 @@ type ChooseAppAndEventSubstepProps = { step: IStep; }; -const optionGenerator = (app: IApp): { label: string; value: string; } => ({ +const optionGenerator = (app: { name: string, key: string, }): { label: string; value: string } => ({ label: app.name as string, value: app.key as string, }); -const getOption = (options: Record[], appKey: IApp["key"]) => options.find(option => option.value === appKey as string) || null; +const getOption = (options: Record[], appKey?: IApp['key']) => + options.find((option) => option.value === (appKey as string)) || null; -function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.ReactElement { +function ChooseAppAndEventSubstep( + props: ChooseAppAndEventSubstepProps +): React.ReactElement { const { substep, expanded = false, @@ -47,59 +50,74 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R const isTrigger = step.type === 'trigger'; - const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }}); + const { data } = useQuery(GET_APPS, { + variables: { onlyWithTriggers: isTrigger }, + }); const apps: IApp[] = data?.getApps; const app = apps?.find((currentApp: IApp) => currentApp.key === step.appKey); - const appOptions = React.useMemo(() => apps?.map((app) => optionGenerator(app)), [apps]); - const actionsOrTriggers = isTrigger ? app?.triggers : app?.actions; - const actionOptions = React.useMemo(() => actionsOrTriggers?.map((trigger) => optionGenerator(trigger)) ?? [], [app?.key]); - const selectedActionOrTrigger = actionsOrTriggers?.find((actionOrTrigger) => actionOrTrigger.key === step?.key) || null; + const appOptions = React.useMemo( + () => apps?.map((app) => optionGenerator(app)), + [apps] + ); + const actionsOrTriggers: Array = (isTrigger ? app?.triggers : app?.actions) || []; + const actionOptions = React.useMemo( + () => actionsOrTriggers.map((trigger) => optionGenerator(trigger)), + [app?.key] + ); + const selectedActionOrTrigger = + actionsOrTriggers.find( + (actionOrTrigger: IAction | ITrigger) => actionOrTrigger.key === step?.key + ); - const { - name, - } = substep; + const { name } = substep; const valid: boolean = !!step.key && !!step.appKey; // placeholders - const onEventChange = React.useCallback((event: React.SyntheticEvent, selectedOption: unknown) => { - if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption as { value: string }; - const option: { value: string } = typedSelectedOption; - const eventKey = option?.value as string; + const onEventChange = React.useCallback( + (event: React.SyntheticEvent, selectedOption: unknown) => { + if (typeof selectedOption === 'object') { + // TODO: try to simplify type casting below. + const typedSelectedOption = selectedOption as { value: string }; + const option: { value: string } = typedSelectedOption; + const eventKey = option?.value as string; - if (step.key !== eventKey) { - onChange({ - step: { - ...step, - key: eventKey, - }, - }); + if (step.key !== eventKey) { + onChange({ + step: { + ...step, + key: eventKey, + }, + }); + } } - } - }, [step, onChange]); + }, + [step, onChange] + ); - const onAppChange = React.useCallback((event: React.SyntheticEvent, selectedOption: unknown) => { - if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption as { value: string }; - const option: { value: string } = typedSelectedOption; - const appKey = option?.value as string; + const onAppChange = React.useCallback( + (event: React.SyntheticEvent, selectedOption: unknown) => { + if (typeof selectedOption === 'object') { + // TODO: try to simplify type casting below. + const typedSelectedOption = selectedOption as { value: string }; + const option: { value: string } = typedSelectedOption; + const appKey = option?.value as string; - if (step.appKey !== appKey) { - onChange({ - step: { - ...step, - key: '', - appKey, - parameters: {}, - }, - }); + if (step.appKey !== appKey) { + onChange({ + step: { + ...step, + key: '', + appKey, + parameters: {}, + }, + }); + } } - } - }, [step, onChange]); + }, + [step, onChange] + ); const onToggle = expanded ? onCollapse : onExpand; @@ -112,14 +130,26 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R valid={valid} /> - + } + renderInput={(params) => ( + + )} value={getOption(appOptions, step.appKey)} onChange={onAppChange} /> @@ -137,17 +167,24 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R disableClearable disabled={editorContext.readOnly} options={actionOptions} - renderInput={(params) => } + renderInput={(params) => ( + + )} value={getOption(actionOptions, step.key)} onChange={onEventChange} /> )} - {isTrigger && selectedActionOrTrigger?.pollInterval && ( + {isTrigger && (selectedActionOrTrigger as ITrigger)?.pollInterval && ( currentApp.key === step.appKey); - const actionsOrTriggers = isTrigger ? app?.triggers : app?.actions; + const actionsOrTriggers: Array = (isTrigger ? app?.triggers : app?.actions) || []; const substeps = React.useMemo( () => - actionsOrTriggers?.find(({ key }) => key === step.key)?.substeps || [], + actionsOrTriggers?.find(({ key }: ITrigger | IAction) => key === step.key)?.substeps || [], [actionsOrTriggers, step?.key] ); @@ -235,7 +235,7 @@ export default function FlowStep( > toggleSubstep(0)} onCollapse={() => toggleSubstep(0)} onSubmit={expandNextStep} @@ -246,11 +246,7 @@ export default function FlowStep( {substeps?.length > 0 && substeps.map( ( - substep: { - name: string; - key: string; - arguments: IField[]; - }, + substep: ISubstep, index: number ) => ( @@ -279,7 +275,7 @@ export default function FlowStep( /> )} - {['chooseConnection', 'testStep'].includes(substep.key) === + {substep.key && ['chooseConnection', 'testStep'].includes(substep.key) === false && ( { return array.slice(0, length); -} +}; const Suggestions = (props: SuggestionsProps) => { - const { - data, - onSuggestionClick = () => null, - } = props; + const { data, onSuggestionClick = () => null } = props; const [current, setCurrent] = React.useState(0); const [listLength, setListLength] = React.useState(SHORT_LIST_LENGTH); @@ -40,41 +37,43 @@ const Suggestions = (props: SuggestionsProps) => { const collapseList = () => { setListLength(SHORT_LIST_LENGTH); - } + }; React.useEffect(() => { setListLength(SHORT_LIST_LENGTH); - }, [current]) + }, [current]); return ( - - Variables - + + + Variables + + {data.map((option: IStep, index: number) => ( <> setCurrent((currentIndex) => currentIndex === index ? null : index)} - sx={{ py: 0.5, }} + onClick={() => + setCurrent((currentIndex) => + currentIndex === index ? null : index + ) + } + sx={{ py: 0.5 }} > - + - {!!option.output?.length && ( - current === index ? : - )} + {!!option.output?.length && + (current === index ? : )} - - {getPartialArray(option.output as any || [], listLength) - .map((suboption: any, index: number) => ( + + {getPartialArray((option.output as any) || [], listLength).map( + (suboption: any, index: number) => ( { primaryTypographyProps={{ variant: 'subtitle1', title: 'Property name', - sx: { fontWeight: 700 } + sx: { fontWeight: 700 }, }} secondary={suboption.value || ''} secondaryTypographyProps={{ @@ -95,24 +94,18 @@ const Suggestions = (props: SuggestionsProps) => { }} /> - )) - } + ) + )} - {option.output?.length > listLength && ( - )} {listLength === Infinity && ( - )} @@ -122,6 +115,6 @@ const Suggestions = (props: SuggestionsProps) => { ); -} +}; export default Suggestions; diff --git a/packages/web/src/components/PowerInput/data.ts b/packages/web/src/components/PowerInput/data.ts index 02198916..a62606e3 100644 --- a/packages/web/src/components/PowerInput/data.ts +++ b/packages/web/src/components/PowerInput/data.ts @@ -1,6 +1,7 @@ import type { IStep } from '@automatisch/types'; -const joinBy = (delimiter = '.', ...args: string[]) => args.filter(Boolean).join(delimiter); +const joinBy = (delimiter = '.', ...args: string[]) => + args.filter(Boolean).join(delimiter); const process = (data: any, parentKey?: any, index?: number): any[] => { if (typeof data !== 'object') { @@ -8,14 +9,19 @@ const process = (data: any, parentKey?: any, index?: number): any[] => { { name: `${parentKey}.${index}`, value: data, - } - ] + }, + ]; } const entries = Object.entries(data); return entries.flatMap(([name, value]) => { - const fullName = joinBy('.', parentKey, (index as number)?.toString(), name); + const fullName = joinBy( + '.', + parentKey, + (index as number)?.toString(), + name + ); if (Array.isArray(value)) { return value.flatMap((item, index) => process(item, fullName, index)); @@ -25,10 +31,12 @@ const process = (data: any, parentKey?: any, index?: number): any[] => { return process(value, fullName); } - return [{ - name: fullName, - value, - }]; + return [ + { + name: fullName, + value, + }, + ]; }); }; @@ -39,12 +47,17 @@ export const processStepWithExecutions = (steps: IStep[]): any[] => { .filter((step: IStep) => { const hasExecutionSteps = !!step.executionSteps?.length; - return hasExecutionSteps + return hasExecutionSteps; }) .map((step: IStep, index: number) => ({ id: step.id, // TODO: replace with step.name once introduced - name: `${index + 1}. ${step.appKey?.charAt(0)?.toUpperCase() + step.appKey?.slice(1)}`, - output: process(step.executionSteps?.[0]?.dataOut || {}, `step.${step.id}`), + name: `${index + 1}. ${ + (step.appKey || '').charAt(0)?.toUpperCase() + step.appKey?.slice(1) + }`, + output: process( + step.executionSteps?.[0]?.dataOut || {}, + `step.${step.id}` + ), })); }; diff --git a/packages/web/src/graphql/queries/get-app.ts b/packages/web/src/graphql/queries/get-app.ts index 5c5aff67..ce3219ed 100644 --- a/packages/web/src/graphql/queries/get-app.ts +++ b/packages/web/src/graphql/queries/get-app.ts @@ -10,42 +10,44 @@ export const GET_APP = gql` authDocUrl primaryColor supportsConnections - fields { - key - label - type - required - readOnly - value - description - docUrl - clickToCopy - } - authenticationSteps { - step - type - name - arguments { - name - value + auth { + fields { + key + label type - properties { + required + readOnly + value + description + docUrl + clickToCopy + } + authenticationSteps { + step + type + name + arguments { name value + type + properties { + name + value + } } } - } - reconnectionSteps { - step - type - name - arguments { - name - value + reconnectionSteps { + step type - properties { + name + arguments { name value + type + properties { + name + value + } } } } diff --git a/packages/web/src/graphql/queries/get-apps.ts b/packages/web/src/graphql/queries/get-apps.ts index 3cdd6e3c..f22a3adf 100644 --- a/packages/web/src/graphql/queries/get-apps.ts +++ b/packages/web/src/graphql/queries/get-apps.ts @@ -10,43 +10,45 @@ export const GET_APPS = gql` primaryColor connectionCount supportsConnections - fields { - key - label - type - required - readOnly - value - placeholder - description - docUrl - clickToCopy - } - authenticationSteps { - step - type - name - arguments { - name - value + auth { + fields { + key + label type - properties { + required + readOnly + value + placeholder + description + docUrl + clickToCopy + } + authenticationSteps { + step + type + name + arguments { name value + type + properties { + name + value + } } } - } - reconnectionSteps { - step - type - name - arguments { - name - value + reconnectionSteps { + step type - properties { + name + arguments { name value + type + properties { + name + value + } } } }