Compare commits

...

221 Commits

Author SHA1 Message Date
Faruk AYDIN
d86dbf7bd9 feat: Add error details logic into execution steps 2022-09-16 16:46:06 +03:00
Faruk AYDIN
8c12d2dc85 docs: Remove github and typeform from available apps 2022-09-16 08:19:43 +02:00
Faruk AYDIN
b8970fc3f8 docs: Hide typeform and github connection setups 2022-09-16 08:19:43 +02:00
Ömer Faruk Aydın
dc8a32b0b4 Merge pull request #507 from automatisch/issue-499
feat: show poll interval in trigger step
2022-09-14 01:12:10 +03:00
Ali BARIN
02deed3e3a feat: show poll interval in trigger step 2022-09-13 23:39:10 +02:00
Ömer Faruk Aydın
e50f641533 Merge pull request #504 from automatisch/issue-498
feat: show CTA for connecting to services
2022-09-13 21:32:12 +03:00
Ali BARIN
bd754da1ed feat: show CTA for connecting to services 2022-09-13 20:26:49 +02:00
Ömer Faruk Aydın
23c568c87c Merge pull request #506 from automatisch/docs/installation
docs: Remind that users can change password after installation
2022-09-13 20:20:21 +03:00
Faruk AYDIN
cc9b047300 docs: Remind that users can change password after installation 2022-09-13 19:23:40 +03:00
Ömer Faruk Aydın
eb3ac23c01 Merge pull request #505 from automatisch/docs/other-pages
License and community pages of docs
2022-09-13 18:47:52 +03:00
Faruk AYDIN
5358d6ce5d docs: Add community page 2022-09-13 18:38:22 +03:00
Faruk AYDIN
c0eaab3254 docs: Add license page 2022-09-13 18:37:16 +03:00
Ömer Faruk Aydın
2a39435413 Merge pull request #503 from automatisch/docs/key-concepts
docs: Revise key concepts page
2022-09-13 18:35:06 +03:00
Faruk AYDIN
01ea316c1f docs: Revise key concepts page 2022-09-13 18:30:51 +03:00
Faruk AYDIN
b253040d0a docs: Complete installation doc 2022-09-12 10:12:50 +02:00
Faruk AYDIN
57748a3541 docs: Revise homepage copy of the documentation 2022-09-12 10:12:50 +02:00
Ömer Faruk Aydın
486eb088cf Merge pull request #496 from automatisch/meta-tags
chore: update title, description and manifest
2022-09-12 01:13:08 +03:00
Ömer Faruk Aydın
c07a741970 Merge pull request #497 from automatisch/BASE_URL_in_docs
chore(docs): accept BASE_URL as environment variable
2022-09-12 01:11:44 +03:00
Ali BARIN
d1c7d5ef70 chore(docs): accept BASE_URL as environment variable 2022-09-11 16:20:49 +02:00
Ali BARIN
4b15dad5ea chore(docs): update title and description 2022-09-11 16:03:46 +02:00
Ali BARIN
577e3fc669 chore(web): update title, description and manifest 2022-09-11 16:03:27 +02:00
Ömer Faruk Aydın
b5c7c6d88b Merge pull request #493 from automatisch/issue-492
feat: add find messages action in Slack
2022-09-11 15:25:11 +03:00
Faruk AYDIN
d812bb431f fix: Throw error before getting slack messages for find messages action 2022-09-11 15:21:25 +03:00
Ali BARIN
3593727d29 feat: add find message action in Slack 2022-09-11 12:33:06 +02:00
Ömer Faruk Aydın
75b536959e Merge pull request #491 from automatisch/issue-489
feat(docker-compose): add worker as service
2022-09-08 16:55:09 +03:00
Ali BARIN
29a9338ad7 feat(docker-compose): add worker as service 2022-09-07 23:25:26 +02:00
Ömer Faruk Aydın
3755a3ce4c Merge pull request #490 from automatisch/issue-488
feat(cli): add start-worker command
2022-09-07 20:56:59 +03:00
Ali BARIN
ec0d0203ae feat(cli): add start-worker command 2022-09-07 18:35:20 +02:00
Ömer Faruk Aydın
72e3a69bd9 Merge pull request #487 from automatisch/stop-exposing-psql-redis-in-docker
chore(docker-compose): stop exposing psql & redis
2022-09-07 12:43:55 +03:00
Ali BARIN
2c61fb0c8b chore(docker-compose): stop exposing psql & redis 2022-09-07 10:58:16 +02:00
dependabot[bot]
56352560a5 chore(deps): bump moment-timezone from 0.5.34 to 0.5.37
Bumps [moment-timezone](https://github.com/moment/moment-timezone) from 0.5.34 to 0.5.37.
- [Release notes](https://github.com/moment/moment-timezone/releases)
- [Changelog](https://github.com/moment/moment-timezone/blob/develop/changelog.md)
- [Commits](https://github.com/moment/moment-timezone/compare/0.5.34...0.5.37)

---
updated-dependencies:
- dependency-name: moment-timezone
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-05 12:09:27 +02:00
Ömer Faruk Aydın
2ac7c17514 Merge pull request #485 from automatisch/issue-484
chore: don't seed user if already seeded
2022-09-05 07:55:07 +03:00
Ali BARIN
a5b3a68588 chore: don't seed user if already seeded 2022-09-05 00:00:03 +02:00
Ömer Faruk Aydın
10dcccf99f Merge pull request #479 from automatisch/feature/add-error-details-column
feat: Add error details jsonb column to execution steps
2022-09-04 19:17:45 +03:00
Faruk AYDIN
d0922d85b3 feat: Add error details jsonb column to execution steps 2022-09-04 19:13:25 +03:00
Ömer Faruk Aydın
fc330e25cf Merge pull request #478 from automatisch/issue-468
feat: add no data alert in test substep and execution
2022-09-02 13:54:34 +03:00
Ali BARIN
dc79d623be feat: add no data alert in test substep and execution 2022-09-01 22:25:57 +02:00
Ömer Faruk Aydın
91f5f1d628 Merge pull request #477 from automatisch/feature/slack-send-a-message-to-channel
refactor: Adjust send a message to channel to use slack client
2022-09-01 23:24:15 +03:00
Faruk AYDIN
e0f055a375 refactor: Adjust send a message to channel to use slack client 2022-09-01 23:20:35 +03:00
Ömer Faruk Aydın
7f3098362a Merge pull request #476 from automatisch/refactor/slack-list-channels
refactor: Adjust slack list channels to use slack client
2022-09-01 22:24:21 +03:00
Faruk AYDIN
26eee1bb63 refactor: Adjust slack list channels to use slack client 2022-09-01 22:18:37 +03:00
Ali BARIN
0246d48584 feat: show test runs in executions 2022-09-01 18:54:58 +02:00
Faruk AYDIN
92053ea25a refactor: Slack authentication by passing flow, connection and step 2022-09-01 17:26:28 +02:00
Ömer Faruk Aydın
d12984f324 Merge pull request #473 from automatisch/chore/limit-available-apps
chore: Remove github and typeform from temporary app list
2022-09-01 14:05:48 +03:00
Faruk AYDIN
0eb28ec1a5 chore: Remove github and typeform from temporary app list 2022-09-01 14:03:31 +03:00
Ömer Faruk Aydın
20f8cca07d Merge pull request #472 from automatisch/feature/twitter-user-followers
feat: Implement new follower of me trigger for twitter
2022-09-01 14:01:59 +03:00
Faruk AYDIN
9613e142c9 feat: Implement new follower of me trigger for twitter 2022-09-01 13:54:33 +03:00
Ömer Faruk Aydın
1cc752b5c7 Merge pull request #470 from automatisch/feature/my-tweets
feat: Implement my tweets trigger
2022-09-01 13:48:48 +03:00
Faruk AYDIN
163629b990 feat: Implement my tweets trigger 2022-09-01 13:34:22 +03:00
Faruk AYDIN
770e115be9 docs: Adjust twitter connection setup wording 2022-08-31 22:41:55 +02:00
Ömer Faruk Aydın
0eb5f3d3e6 Merge pull request #467 from automatisch/wait-for-postgres-in-docker-compose
chore: wait for postgres in docker compose
2022-08-31 22:42:58 +03:00
Ali BARIN
6e9a9992c0 chore: wait for postgres in docker compose 2022-08-31 21:29:05 +02:00
Ömer Faruk Aydın
827fef8a05 Merge pull request #465 from automatisch/refactor/user-tweets
refactor: Use plural wording for user tweets trigger
2022-08-31 19:09:59 +03:00
Ömer Faruk Aydın
b53fcdebe3 Merge pull request #466 from automatisch/docker-compose
chore: add docker-compose support
2022-08-31 19:08:37 +03:00
Faruk AYDIN
6537a1c649 refactor: Use plural wording for user tweets trigger 2022-08-31 19:07:42 +03:00
Faruk AYDIN
abaad7cb82 feat: Implement search tweets trigger 2022-08-31 16:36:39 +02:00
Ali BARIN
ba140f05d3 chore: add docker-compose support 2022-08-31 16:34:15 +02:00
Ömer Faruk Aydın
81a444e056 Merge pull request #462 from automatisch/feature/user-tweet-pagination
feat: Implement draft version of pagination with user tweet trigger
2022-08-31 12:57:26 +03:00
Faruk AYDIN
db2c3556de refactor: Get last execution and find last internal ID 2022-08-31 12:53:19 +03:00
Faruk AYDIN
9bd1447bcf feat: Insert execution even though there is no new data fetched 2022-08-30 15:14:19 +03:00
Faruk AYDIN
fda957b1f6 fix: Adjust response types for twitter auth and endpoints 2022-08-30 14:26:56 +03:00
Faruk AYDIN
29abf702bd chore: Use API Key and API Secret placeholders for twitter connection 2022-08-30 14:18:35 +03:00
Faruk AYDIN
5ddb5ab6fa feat: Implement draft version of pagination with user tweet trigger 2022-08-29 23:11:28 +03:00
Ali BARIN
e997aa6c81 chore: introduce docker compose 2022-08-29 11:45:39 +02:00
Ali BARIN
6271cedc25 feat: stop hiding app bar on scroll 2022-08-25 17:26:47 +02:00
Ali BARIN
743b6d6587 docs: update slack connection path 2022-08-25 16:17:38 +02:00
Ömer Faruk Aydın
ee4303a676 Merge pull request #457 from automatisch/issue-454
feat: add status in flow row
2022-08-25 00:34:07 +03:00
Ali BARIN
c361b9af8d feat: add status in flow row 2022-08-24 23:22:56 +02:00
Ömer Faruk Aydın
f66656fd4e Merge pull request #456 from automatisch/issue-455
feat: make flow editor read only when published
2022-08-24 22:24:44 +03:00
Ali BARIN
be141e55a9 feat: make flow editor read only when published 2022-08-24 21:01:51 +02:00
Ömer Faruk Aydın
5ed3b9230e Merge pull request #453 from automatisch/refactor/flow-step-params
refactor: Pass connection, flow and step as params to apps
2022-08-21 22:03:28 +03:00
Faruk AYDIN
44e3de8534 refactor: Pass connection, flow and step as params to apps 2022-08-21 20:25:04 +03:00
Faruk AYDIN
cd6c5216ff refactor: Adjust create tweet action to use new http client 2022-08-20 11:25:20 +02:00
Ali BARIN
17010f9283 feat: add loading button in test substep 2022-08-20 11:20:38 +02:00
Faruk AYDIN
5fb988ae2d feat: Add published_at column to flows and adjust update flow status 2022-08-17 20:12:18 +02:00
Ömer Faruk Aydın
c4a3f19bba Merge pull request #448 from automatisch/inline-test-errors
feat: inline errors in test substep
2022-08-17 11:21:12 +03:00
Ali BARIN
347c9c9455 feat: inline errors in test substep 2022-08-14 18:35:13 +02:00
Ömer Faruk Aydın
731443ab7d Merge pull request #447 from automatisch/feat/use-vitepress-for-docs
feat: Adjust docs to use vitepress
2022-08-13 19:54:18 +03:00
Faruk AYDIN
898ab41167 fix: Remove broken links for docs 2022-08-13 19:54:10 +03:00
Faruk AYDIN
cf4f5bd084 feat: Change edit url of docs 2022-08-13 19:42:13 +03:00
Faruk AYDIN
c7e55fe3e0 feat: Change docs color set to apply Automatisch colors 2022-08-13 19:38:27 +03:00
Faruk AYDIN
b737cf68ba feat: Adjust docs to use vitepress 2022-08-13 16:54:47 +03:00
Ömer Faruk Aydın
c95622affe Merge pull request #446 from automatisch/feature/show-flow-count-with-connections
feat: Expose flow count with connections of getApp query
2022-08-13 11:59:30 +03:00
Ali BARIN
c1b637b284 feat: display flow count on per connection 2022-08-13 01:39:41 +02:00
Faruk AYDIN
bb37299c5b feat: Expose flow count with connections of getApp query 2022-08-13 01:50:03 +03:00
Ömer Faruk Aydın
2e9d5fb2fc Merge pull request #445 from automatisch/issue-442
fix: reset GQL cache on logout
2022-08-13 00:00:20 +03:00
Ali BARIN
2aeabd4be3 fix: reset GQL cache on logout 2022-08-12 20:56:04 +02:00
Ömer Faruk Aydın
a4a9d60d68 Merge pull request #444 from automatisch/issue-440
feat: add no result found UI on executions
2022-08-12 20:31:21 +03:00
Ömer Faruk Aydın
dac9276b9e Merge pull request #443 from automatisch/issue-439
feat: add no result found UI on apps
2022-08-12 20:30:56 +03:00
Ali BARIN
38700256b0 feat: add no result found UI on executions 2022-08-12 19:19:07 +02:00
Ali BARIN
a5f391d2dc feat: add no result found UI on apps 2022-08-12 18:14:30 +02:00
Ali BARIN
77d7260698 feat: add no result found UI on flows 2022-08-12 18:09:18 +02:00
Ömer Faruk Aydın
782b2bafaa Merge pull request #437 from automatisch/issue-436
feat: add "go back to flows" tooltip in editor
2022-08-12 19:03:06 +03:00
Ali BARIN
a35bee0bc9 feat: add "go back to flows" tooltip in editor 2022-08-12 15:31:47 +02:00
Ömer Faruk Aydın
c7d5584cd9 Merge pull request #435 from automatisch/refactor/user-tweet-trigger
refactor: Adjust architecture for twitter client and user tweet trigger
2022-08-12 00:13:48 +03:00
Faruk AYDIN
bb68b2dea1 refactor: Adjust architecture for twitter client and user tweet trigger 2022-08-12 00:11:06 +03:00
Ömer Faruk Aydın
3b587de138 Merge pull request #434 from automatisch/issue-433
feat: show not found UI in app/connection flows
2022-08-11 22:10:07 +03:00
Ali BARIN
c827ce4270 feat: show not found UI in app/connection flows 2022-08-11 20:26:35 +02:00
Ömer Faruk Aydın
c6c3cbb1d3 Merge pull request #432 from automatisch/issue-426
feat: show related flows for connections
2022-08-11 20:20:49 +03:00
Ali BARIN
ad97fae883 feat: show related flows for connections 2022-08-11 18:44:44 +02:00
Ali BARIN
a5b6e66e22 feat: add header with id, name, date in Execution 2022-08-11 11:00:14 +02:00
Ali BARIN
5d7daa8886 feat: add getExecution query 2022-08-11 11:00:14 +02:00
Ömer Faruk Aydın
47ba42f9f8 Merge pull request #430 from automatisch/issue-425
feat: add connectionId filter in getFlows query
2022-08-10 22:43:32 +03:00
Ali BARIN
04f8a71244 feat: add connectionId filter in getFlows query 2022-08-10 21:21:27 +02:00
Ömer Faruk Aydın
3148118784 Merge pull request #428 from automatisch/issue-427
fix: arrange mobile layout in ExecutionRow
2022-08-09 20:59:38 +03:00
Ali BARIN
b7c4a63d2b fix: arrange mobile layout in ExecutionRow 2022-08-09 19:15:19 +02:00
Ömer Faruk Aydın
a4abd7e1cd Merge pull request #421 from automatisch/fix-pagination-get-execution-steps
fix: remove offset pagination in getExecutionSteps
2022-08-09 18:40:02 +03:00
Ali BARIN
0b01a6386d fix: remove offset pagination in getExecutionSteps 2022-08-09 17:01:20 +02:00
Ömer Faruk Aydın
fa75f11eaf Merge pull request #420 from automatisch/issue-417
fix: make flow.name required with 1 minimum length
2022-08-08 21:08:00 +03:00
Ali BARIN
a1b360a172 fix: make flow.name required with 1 minimum length 2022-08-08 19:44:02 +02:00
Ömer Faruk Aydın
e0a76ea918 Merge pull request #395 from automatisch/issue-369
feat: add non-auth apps and flowCount in getConnectedApps
2022-08-08 20:21:51 +03:00
Ömer Faruk Aydın
7990c68d65 Merge pull request #419 from automatisch/issue-418
fix: update account wording with connection
2022-08-08 19:43:04 +03:00
Faruk AYDIN
8c5d95796f refactor: Adjust getConnectedApps graphql query 2022-08-08 19:39:11 +03:00
Ali BARIN
b901a396bf fix: update account wording with connection 2022-08-08 18:24:36 +02:00
Ali BARIN
fa92375e33 fix: polish top whitespace in executions title 2022-08-07 19:20:04 +02:00
Ali BARIN
04b3d93d5d feat: create action by default on flow creation 2022-08-07 19:19:53 +02:00
Ömer Faruk Aydın
1f8bc9cfbd Merge pull request #411 from automatisch/issue-383
feat: perform remote search on flows
2022-08-07 16:42:54 +03:00
Ali BARIN
71939f9163 feat: perform remote search on flows 2022-08-07 15:39:47 +02:00
Ömer Faruk Aydın
0ea8f3a01a Merge pull request #410 from automatisch/issue-384
feat: add search capability by name in getFlows
2022-08-07 16:23:31 +03:00
Ömer Faruk Aydın
ffa49149c9 Merge pull request #409 from automatisch/issue-380
feat: add flow app icons and relative date in execution row
2022-08-07 16:23:00 +03:00
Ömer Faruk Aydın
40a35691fe Merge pull request #408 from automatisch/issue-406
feat: add pagination on app flows
2022-08-07 16:21:32 +03:00
Ömer Faruk Aydın
908a3126f1 Merge pull request #407 from automatisch/issue-375
refactor: unify flow rows
2022-08-07 16:20:53 +03:00
Ali BARIN
726707afe8 feat: add search capability by name in getFlows 2022-08-07 15:10:11 +02:00
Ali BARIN
3c926adeca feat: add relative date in execution row 2022-08-07 14:56:05 +02:00
Ali BARIN
fc681d9ebc feat: add flow app icons in execution row 2022-08-07 14:50:16 +02:00
Ali BARIN
364f53142c feat: add pagination on app flows 2022-08-07 14:34:58 +02:00
Ali BARIN
2764aa2c06 refactor: unify flow rows 2022-08-07 14:13:52 +02:00
Ali BARIN
ca141b1076 feat: downsize flow app icons 2022-08-07 12:17:34 +02:00
Faruk AYDIN
2e980664ac fix: Order associated steps by position for the flow 2022-08-07 11:54:44 +02:00
Ali BARIN
03cfa8b904 fix: remove last non-existing step app icon 2022-08-07 00:26:19 +02:00
Ömer Faruk Aydın
c5bb07a768 Merge pull request #400 from automatisch/fix/flows-group-by
fix: Add group by to differentiate flows
2022-08-07 01:24:12 +03:00
Faruk AYDIN
3a1e8b4bbd fix: Add group by to differentiate flows 2022-08-07 01:21:24 +03:00
Ömer Faruk Aydın
0e749936a6 Merge pull request #373 from automatisch/issue-367
Enhance flow row with used apps, date and context menu
2022-08-07 01:11:22 +03:00
Ali BARIN
744b31aad6 feat: add dynamic flow app icons 2022-08-06 23:41:47 +02:00
Ali BARIN
82bdf9d3b1 feat: add intermediate step count in flow apps 2022-08-06 23:41:47 +02:00
Ali BARIN
8c59bd664e feat: add used app icons in FlowRow 2022-08-06 23:41:47 +02:00
Ali BARIN
15aaada3fe feat: add relative time and context menu in flows 2022-08-06 23:41:47 +02:00
Ömer Faruk Aydın
0fdbe4d39b Merge pull request #399 from automatisch/refactor/flows-query
refactor: Use currentUser with context for flows query
2022-08-06 23:40:50 +03:00
Faruk AYDIN
280c7832ae refactor: Use currentUser with context for flows query 2022-08-06 23:31:09 +03:00
Ömer Faruk Aydın
1f9dd6a3bc Merge pull request #398 from automatisch/feature/expose-icon-url-with-steps
feat: Expose iconUrl together with steps
2022-08-06 23:17:25 +03:00
Faruk AYDIN
e7f12f4a06 feat: Expose iconUrl together with steps 2022-08-06 23:11:42 +03:00
dependabot[bot]
5a24b9ec8a chore(deps): bump parse-url from 6.0.0 to 6.0.5 (#397)
Bumps [parse-url](https://github.com/IonicaBizau/parse-url) from 6.0.0 to 6.0.5.
- [Release notes](https://github.com/IonicaBizau/parse-url/releases)
- [Commits](https://github.com/IonicaBizau/parse-url/compare/6.0.0...6.0.5)

---
updated-dependencies:
- dependency-name: parse-url
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ömer Faruk Aydın <omerfaruk26@gmail.com>
2022-08-06 19:42:38 +03:00
Ömer Faruk Aydın
1d8a72e03b Merge pull request #394 from automatisch/issue-382
feat: add pagination on /flows
2022-08-06 19:42:23 +03:00
Ömer Faruk Aydın
ef987eae36 Merge pull request #393 from automatisch/issue-389
feat: add paging capability in getFlows query
2022-08-06 19:36:10 +03:00
Faruk AYDIN
dc4899c240 refactor: Adjust flow query to use joinRelated 2022-08-06 15:03:43 +03:00
Ömer Faruk Aydın
88a780f008 Merge pull request #354 from automatisch/dependabot/npm_and_yarn/terser-5.14.2
chore(deps): bump terser from 5.10.0 to 5.14.2
2022-08-06 13:17:13 +03:00
Ömer Faruk Aydın
6fc13f3707 Merge pull request #336 from automatisch/dependabot/npm_and_yarn/parse-url-6.0.2
chore(deps): bump parse-url from 6.0.0 to 6.0.2
2022-08-06 13:16:54 +03:00
Ömer Faruk Aydın
6f1b9b8fe7 Merge pull request #391 from automatisch/issue-374
feat: add loading indicator in AddNewAppConnection
2022-08-06 13:16:23 +03:00
Ali BARIN
533d73d718 feat: add non-auth apps and flowCount in getConnectedApps 2022-08-05 16:13:50 +02:00
Ali BARIN
63241d2438 feat: add pagination on /flows 2022-08-05 15:21:21 +02:00
Ali BARIN
5a177b330a feat: add paging capability in getFlows query 2022-08-05 13:40:03 +02:00
Ali BARIN
ae8f701e5c fix: use cache-and-network fetchPolicy for consistency 2022-08-04 22:04:36 +02:00
Ali BARIN
744c927b69 fix: use non-draft connectionCount in getConnectedApps 2022-08-04 22:04:36 +02:00
Ali BARIN
6ca1b99947 fix: hide form field error if not touched 2022-08-04 21:56:00 +02:00
Ali BARIN
cdb018dcd2 feat: update color scheme in JSONViewer 2022-08-04 21:55:50 +02:00
Ali BARIN
03efe3f0b3 fix: replace current history entry on redirections 2022-08-04 21:36:18 +02:00
Ali BARIN
913a2773c1 feat: make errors inline in add app connection 2022-08-04 21:33:59 +02:00
Ali BARIN
10c64167d7 feat: link typography logo to dashboard 2022-08-04 21:33:49 +02:00
Ali BARIN
a624a8d8b5 refactor: add version in app config 2022-08-04 21:31:09 +02:00
Ali BARIN
ff09a836b4 feat: highlight newer versions in notifications 2022-08-04 21:31:09 +02:00
Ali BARIN
e7c734c55e feat: expose version with healthcheck query 2022-08-04 21:31:09 +02:00
Ali BARIN
5271af8b94 feat: add supportsConnections support in App 2022-08-04 21:23:23 +02:00
Ali BARIN
df55d9fdd9 feat: add loading indicator in AddNewAppConnection 2022-08-03 21:03:22 +02:00
Ali BARIN
c7ff9dc162 chore: upgrade @apollo/client to 3.6.9 2022-08-03 21:02:53 +02:00
Ömer Faruk Aydın
f7ab2b667c Merge pull request #364 from automatisch/docs/twitter-auth
docs: Twitter authentication guide
2022-07-28 00:32:11 +03:00
Ömer Faruk Aydın
b89086d3b8 Merge pull request #366 from automatisch/feature/connection-draft-column
feat: Add draft column to connections
2022-07-27 23:28:44 +03:00
Faruk AYDIN
448a1a49b4 fix: Import App model for create connection query 2022-07-27 23:25:20 +03:00
Ali BARIN
98d7969a1e feat: check app existence in createConnection 2022-07-27 21:11:31 +02:00
Ali BARIN
d513e03138 feat: show connection upon verification 2022-07-27 16:40:21 +02:00
Ali BARIN
a5367c3770 feat: add app field in verifyConnection 2022-07-27 16:01:39 +02:00
Ali BARIN
58d5847eed refactor: remove app field out of createConnection 2022-07-27 16:01:19 +02:00
Faruk AYDIN
02b22740b2 docs: Add twitter authentication guide 2022-07-27 13:55:14 +03:00
Faruk AYDIN
70d59c6c64 feat: Add draft column to connections 2022-07-27 13:54:10 +03:00
Ömer Faruk Aydın
095948e737 Merge pull request #363 from automatisch/fix-diagnostic-track
fix: keep this in diagnosticInfo timeout
2022-07-27 12:03:00 +03:00
Ali BARIN
e6cec355cc fix: keep this in diagnosticInfo timeout 2022-07-27 10:28:17 +02:00
Ömer Faruk Aydın
351f152664 Merge pull request #362 from automatisch/refactor/use-twitter-api-v2-to-verify
refactor: Use twitter api v2 endpoint to verify authentication
2022-07-27 11:20:16 +03:00
Faruk AYDIN
04450b8793 refactor: Use twitter api v2 endpoint to verify authentication 2022-07-27 00:49:19 +03:00
Ömer Faruk Aydın
76612e81b2 Merge pull request #361 from automatisch/refactor/remove-redundant-get-app-connections
refactor: Remove redundant getAppConnections query
2022-07-27 00:21:11 +03:00
Faruk AYDIN
cb26948db6 refactor: Remove redundant getAppConnections query 2022-07-26 23:42:00 +03:00
Ömer Faruk Aydın
18cdd226bb Merge pull request #357 from automatisch/fix/typeform-connection-typo
docs: Fix typeform connection typo
2022-07-23 20:43:42 +03:00
Faruk AYDIN
2199970e50 docs: Fix typeform connection typo 2022-07-23 20:41:00 +03:00
Ömer Faruk Aydın
8389c9fdad Merge pull request #356 from automatisch/docs/typeform-connection-guide
docs: Add guide for typeform connection setup
2022-07-23 20:22:27 +03:00
Faruk AYDIN
0d06ec9a22 docs: Add guide for typeform connection setup 2022-07-23 20:19:13 +03:00
Ömer Faruk Aydın
ce56f166cc Merge pull request #355 from automatisch/docs/request-integration
docs: Add request an integration guide
2022-07-20 16:43:44 +03:00
Faruk AYDIN
a07938d517 docs: Add request an integration guide 2022-07-20 16:38:55 +03:00
dependabot[bot]
7d81a4bdd2 chore(deps): bump terser from 5.10.0 to 5.14.2
Bumps [terser](https://github.com/terser/terser) from 5.10.0 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-20 13:11:09 +00:00
Ömer Faruk Aydın
c4bffe7f9d Merge pull request #353 from automatisch/docs/slack-connection-guide
docs: Add setup guide for slack connection
2022-07-20 16:03:43 +03:00
Faruk AYDIN
0c2caccc7c docs: Add setup guide for slack connection 2022-07-20 16:00:10 +03:00
Ömer Faruk Aydın
d3c9f7a491 Merge pull request #352 from automatisch/docs/scheduler-connection
docs: Add an explanation of scheduler connection
2022-07-20 14:17:56 +03:00
Faruk AYDIN
f09fa0fe7c docs: Add an explanation of scheduler connection 2022-07-20 14:15:05 +03:00
Ömer Faruk Aydın
8f1fbf086f Merge pull request #351 from automatisch/chore/change-docs-sidebar-order
chore: Change sidebar order for docs
2022-07-20 14:11:11 +03:00
Faruk AYDIN
272c666d23 chore: Change sidebar order for docs 2022-07-20 13:16:09 +03:00
Ömer Faruk Aydın
a1db89700b Merge pull request #350 from automatisch/docs/github-connection
docs: Add how to create github connection guide
2022-07-20 13:15:27 +03:00
Faruk AYDIN
311f0a747d docs: Add how to create github connection guide 2022-07-20 13:10:12 +03:00
Ömer Faruk Aydın
69298857a9 Merge pull request #349 from automatisch/docs/connections
docs: Add draft version of connections page
2022-07-20 12:29:25 +03:00
Faruk AYDIN
c98680fa59 docs: Add draft version of connections page 2022-07-20 12:23:45 +03:00
Ömer Faruk Aydın
5f357afcd6 Merge pull request #348 from automatisch/refactor/use-http-client-for-twitter-oauth
refactor: Adjust twitter authentication to use http client
2022-07-19 18:33:34 +03:00
Faruk AYDIN
997775e54b refactor: Adjust twitter authentication to use http client 2022-07-19 16:45:47 +03:00
Ömer Faruk Aydın
2cf79e27de Merge pull request #347 from automatisch/refactor/use-http-client-for-github-auth
refactor: Use http client to authenticate github
2022-07-18 01:34:41 +03:00
Faruk AYDIN
95d03e00da refactor: Use http client to authenticate github 2022-07-18 01:31:07 +03:00
Ömer Faruk Aydın
c85aadf006 Merge pull request #345 from automatisch/refactor/use-http-client-for-slack-auth
refactor: Use http client for slack authentication
2022-07-17 00:46:53 +03:00
Faruk AYDIN
e9ffb7ef82 refactor: Use http client for slack authentication 2022-07-17 00:44:47 +03:00
Ömer Faruk Aydın
4237972c86 Merge pull request #344 from automatisch/chore/adjust-connections-order
chore: Adjust connections order by created date descending
2022-07-16 23:40:50 +03:00
Faruk AYDIN
b69009f8b6 chore: Adjust connections order by created date descending 2022-07-16 21:38:35 +03:00
Ömer Faruk Aydın
6fa6ee7a1b Merge pull request #342 from automatisch/refactor/use-http-client-for-typeform-auth
refactor: Use http client to authenticate typeform
2022-07-16 00:57:23 +03:00
Faruk AYDIN
7cf1bfffbc refactor: Use http client to authenticate typeform 2022-07-16 00:54:55 +03:00
Ömer Faruk Aydın
903b0f52b9 Merge pull request #340 from automatisch/feature/basic-http-client
feat: Implement basic http client
2022-07-15 19:59:03 +03:00
Faruk AYDIN
16e299a12d feat: Implement basic http client 2022-07-15 19:56:48 +03:00
Ömer Faruk Aydın
e950d73742 Merge pull request #338 from automatisch/fix/scheduler-app-type
fix: Add scheduler to AvailableAppsEnumType
2022-07-14 23:44:06 +03:00
Faruk AYDIN
b44cccb972 fix: Add scheduler to AvailableAppsEnumType 2022-07-14 16:26:40 +03:00
Ömer Faruk Aydın
5d4ef1bbe8 Merge pull request #337 from automatisch/chore/restrict-apps
chore: Restrict exposed apps until triggers/actions are complete
2022-07-14 15:37:05 +03:00
Faruk AYDIN
12a6912d97 chore: Restrict exposed apps until triggers/actions are complete 2022-07-14 15:33:03 +03:00
dependabot[bot]
2740f8e23d chore(deps): bump parse-url from 6.0.0 to 6.0.2
Bumps [parse-url](https://github.com/IonicaBizau/parse-url) from 6.0.0 to 6.0.2.
- [Release notes](https://github.com/IonicaBizau/parse-url/releases)
- [Commits](https://github.com/IonicaBizau/parse-url/commits)

---
updated-dependencies:
- dependency-name: parse-url
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-06 01:36:01 +00:00
Ömer Faruk Aydın
d0814477eb Merge pull request #334 from automatisch/chore/ts-node-dev
chore: Use ts-node-dev instead of nodemon for backend dev script
2022-05-13 13:02:24 +02:00
Faruk AYDIN
cdb256390c chore: Use ts-node-dev instead of nodemon for backend dev script 2022-05-13 12:58:03 +02:00
Ömer Faruk Aydın
9895cc1488 Merge pull request #333 from automatisch/fix/connection-association
fix: Fetch connection while getting trigger for step
2022-05-13 12:53:23 +02:00
Faruk AYDIN
fad2495941 fix: Fetch connection while getting trigger for step 2022-05-13 12:48:21 +02:00
Ömer Faruk Aydın
f9fa7c4094 Merge pull request #332 from automatisch/feature/env-flag-for-bullmq-dashboard
feat: Enable bullmq dashboard with environment variable
2022-05-13 11:44:30 +02:00
Faruk AYDIN
a5538a07f1 feat: Enable bullmq dashboard with environment variable 2022-05-13 11:40:52 +02:00
Ömer Faruk Aydın
d1b46df78a Merge pull request #331 from automatisch/fix/graceful-shutdown-worker
fix: Implement graceful shutdown for worker and queue scheduler
2022-05-13 10:26:55 +02:00
Faruk AYDIN
57186bf85d fix: Implement graceful shutdown for worker and queue scheduler 2022-05-13 10:22:58 +02:00
228 changed files with 4010 additions and 4503 deletions

View File

@@ -0,0 +1,47 @@
version: "3.9"
services:
automatisch:
build:
context: ../images/wait-for-postgres
network: host
ports:
- "3000:3000"
depends_on:
- postgres
- redis
environment:
- HOST=localhost
- PROTOCOL=http
- PORT=3000
- APP_ENV=production
- REDIS_HOST=redis
- POSTGRES_HOST=postgres
- POSTGRES_DATABASE=automatisch
- POSTGRES_USERNAME=automatisch_user
volumes:
- automatisch_storage:/automatisch/storage
worker:
build:
context: ../images/plain
network: host
depends_on:
- automatisch
environment:
- APP_ENV=production
- REDIS_HOST=redis
- POSTGRES_HOST=postgres
- POSTGRES_DATABASE=automatisch
- POSTGRES_USERNAME=automatisch_user
command: automatisch start-worker --env-file /automatisch/storage/.env
volumes:
- automatisch_storage:/automatisch/storage
postgres:
image: "postgres:14.5"
environment:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: automatisch
POSTGRES_USER: automatisch_user
redis:
image: "redis:7.0.4"
volumes:
automatisch_storage:

View File

@@ -0,0 +1,11 @@
# syntax=docker/dockerfile:1
FROM node:16
WORKDIR /automatisch
# npm registry for dev purposes
RUN npm config set fetch-retry-maxtimeout 5000
RUN npm config set fetch-retry-mintimeout 3000
RUN npm set registry http://localhost:5000
# npm registry for dev purposes
RUN yarn global add @automatisch/cli

View File

@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
FROM node:16
WORKDIR /automatisch
RUN apt-get update && apt-get install -y postgresql-client
COPY ./wait-for-postgres.sh /automatisch/wait-for-postgres.sh
# npm registry for dev purposes
RUN npm config set fetch-retry-maxtimeout 5000
RUN npm config set fetch-retry-mintimeout 3000
RUN npm set registry http://localhost:5000
# npm registry for dev purposes
RUN mkdir -p /automatisch/storage
RUN touch /automatisch/storage/.env
RUN echo "ENCRYPTION_KEY=$(openssl rand -base64 36)" >> /automatisch/storage/.env
RUN echo "APP_SECRET_KEY=$(openssl rand -base64 36)" >> /automatisch/storage/.env
RUN yarn global add @automatisch/cli
EXPOSE 3000
CMD sh /automatisch/wait-for-postgres.sh automatisch start --env-file=/automatisch/storage/.env

View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
until psql -h "$POSTGRES_HOST" -U "$POSTGRES_USERNAME" -d "$POSTGRES_HOST" -c '\q'; do
>&2 echo "Postgres is unavailable - sleeping"
sleep 1
done
>&2 echo "Postgres is up - executing command"
exec "$@"

View File

@@ -13,3 +13,4 @@ ENCRYPTION_KEY=sample-encryption-key
APP_SECRET_KEY=sample-app-secret-key APP_SECRET_KEY=sample-app-secret-key
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_HOST=127.0.0.1 REDIS_HOST=127.0.0.1
ENABLE_BULLMQ_DASHBOARD=false

View File

@@ -12,8 +12,14 @@ export async function createUser(email = 'user@automatisch.io', password = 'samp
}; };
try { try {
const user = await User.query().insertAndFetch(userParams); const userCount = await User.query().resultSize();
logger.info(`User has been saved: ${user.email}`);
if (userCount === 0) {
const user = await User.query().insertAndFetch(userParams);
logger.info(`User has been saved: ${user.email}`);
} else {
logger.info('No need to seed a user.');
}
} catch (err) { } catch (err) {
if ((err as any).nativeError.code !== UNIQUE_VIOLATION_CODE) { if ((err as any).nativeError.code !== UNIQUE_VIOLATION_CODE) {
throw err; throw err;

1
packages/backend/database-utils.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export * from './dist/bin/database/utils';

View File

@@ -0,0 +1,2 @@
/* eslint-disable */
module.exports = require('./dist/bin/database/utils');

View File

@@ -1,2 +1 @@
export * as utils from './dist/bin/database/utils'; export * from './dist/src/config/database';
export * as database from './dist/src/config/database';

View File

@@ -1,3 +1,2 @@
/* eslint-disable */ /* eslint-disable */
module.exports.utils = require('./dist/bin/database/utils'); module.exports = require('./dist/src/config/database');
module.exports.database = require('./dist/src/config/database');

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "> TODO: description", "description": "> TODO: description",
"scripts": { "scripts": {
"dev": "nodemon --watch 'src/**/*.ts' --watch 'bin/**/*.ts' --exec 'ts-node' src/server.ts --ext ts,json", "dev": "ts-node-dev src/server.ts",
"worker": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/worker.ts", "worker": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/worker.ts",
"build": "tsc && yarn copy-statics", "build": "tsc && yarn copy-statics",
"build:watch": "nodemon --watch 'src/**/*.ts' --watch 'bin/**/*.ts' --exec yarn build --ext ts", "build:watch": "nodemon --watch 'src/**/*.ts' --watch 'bin/**/*.ts' --exec yarn build --ext ts",
@@ -16,7 +16,8 @@
"db:migration:create": "knex migrate:make", "db:migration:create": "knex migrate:make",
"db:rollback": "knex migrate:rollback", "db:rollback": "knex migrate:rollback",
"db:migrate": "knex migrate:latest", "db:migrate": "knex migrate:latest",
"copy-statics": "copyfiles src/**/*.{graphql,json,svg} dist" "copy-statics": "copyfiles src/**/*.{graphql,json,svg} dist",
"prepack": "yarn build"
}, },
"dependencies": { "dependencies": {
"@automatisch/web": "0.1.0", "@automatisch/web": "0.1.0",
@@ -24,7 +25,6 @@
"@gitbeaker/node": "^35.6.0", "@gitbeaker/node": "^35.6.0",
"@graphql-tools/graphql-file-loader": "^7.3.4", "@graphql-tools/graphql-file-loader": "^7.3.4",
"@graphql-tools/load": "^7.5.2", "@graphql-tools/load": "^7.5.2",
"@octokit/oauth-methods": "^1.2.6",
"@rudderstack/rudder-sdk-node": "^1.1.2", "@rudderstack/rudder-sdk-node": "^1.1.2",
"@slack/bolt": "3.10.0", "@slack/bolt": "3.10.0",
"@types/luxon": "^2.3.1", "@types/luxon": "^2.3.1",
@@ -53,6 +53,7 @@
"luxon": "2.3.1", "luxon": "2.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"nodemailer": "6.7.0", "nodemailer": "6.7.0",
"oauth-1.0a": "^2.2.6",
"objection": "^3.0.0", "objection": "^3.0.0",
"octokit": "^1.7.1", "octokit": "^1.7.1",
"pg": "^8.7.1", "pg": "^8.7.1",
@@ -75,14 +76,19 @@
"test": "__tests__" "test": "__tests__"
}, },
"files": [ "files": [
"dist",
"bin", "bin",
"src", "src",
"server.js", "server.js",
"server.d.ts", "server.d.ts",
"worker.js",
"worker.d.ts",
"logger.js", "logger.js",
"logger.d.ts", "logger.d.ts",
"database.js", "database.js",
"database.d.ts" "database.d.ts",
"database-utils.js",
"database-utils.d.ts"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
@@ -109,7 +115,8 @@
"ava": "^3.15.0", "ava": "^3.15.0",
"nodemon": "^2.0.13", "nodemon": "^2.0.13",
"sinon": "^11.1.2", "sinon": "^11.1.2",
"ts-node": "^10.2.1" "ts-node": "^10.2.1",
"ts-node-dev": "^1.1.8"
}, },
"ava": { "ava": {
"files": [ "files": [

View File

@@ -15,13 +15,13 @@ import {
} from './helpers/create-bull-board-handler'; } from './helpers/create-bull-board-handler';
import injectBullBoardHandler from './helpers/inject-bull-board-handler'; import injectBullBoardHandler from './helpers/inject-bull-board-handler';
if (appConfig.appEnv === 'development') { if (appConfig.enableBullMQDashboard) {
createBullBoardHandler(serverAdapter); createBullBoardHandler(serverAdapter);
} }
const app = express(); const app = express();
if (appConfig.appEnv === 'development') { if (appConfig.enableBullMQDashboard) {
injectBullBoardHandler(app, serverAdapter); injectBullBoardHandler(app, serverAdapter);
} }

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/discord/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/discord/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/discord", "docUrl": "https://automatisch.io/docs/discord",
"primaryColor": "5865f2", "primaryColor": "5865f2",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/firebase/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/firebase/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/firebase", "docUrl": "https://automatisch.io/docs/firebase",
"primaryColor": "ffca28", "primaryColor": "ffca28",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/flickr/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/flickr/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/flickr", "docUrl": "https://automatisch.io/docs/flickr",
"primaryColor": "000000", "primaryColor": "000000",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",
@@ -222,8 +223,8 @@
"description": "Triggers when you favorite a photo.", "description": "Triggers when you favorite a photo.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "testStep", "key": "testStep",
@@ -237,8 +238,8 @@
"description": "Triggers when you add a new photo in an album.", "description": "Triggers when you add a new photo in an album.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -275,8 +276,8 @@
"description": "Triggers when you add a new photo.", "description": "Triggers when you add a new photo.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "testStep", "key": "testStep",
@@ -290,8 +291,8 @@
"description": "Triggers when you create a new album.", "description": "Triggers when you create a new album.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "testStep", "key": "testStep",

View File

@@ -4,35 +4,19 @@ import type {
IField, IField,
IJSONObject, IJSONObject,
} from '@automatisch/types'; } from '@automatisch/types';
import { import HttpClient from '../../helpers/http-client';
getWebFlowAuthorizationUrl, import { URLSearchParams } from 'url';
exchangeWebFlowCode,
checkToken,
} from '@octokit/oauth-methods';
export default class Authentication implements IAuthentication { export default class Authentication implements IAuthentication {
appData: IApp; appData: IApp;
connectionData: IJSONObject; connectionData: IJSONObject;
scopes: string[] = [ scopes: string[] = ['read:org', 'repo', 'user'];
'read:org', client: HttpClient;
'repo',
'user',
];
client: {
getWebFlowAuthorizationUrl: typeof getWebFlowAuthorizationUrl;
exchangeWebFlowCode: typeof exchangeWebFlowCode;
checkToken: typeof checkToken;
};
constructor(appData: IApp, connectionData: IJSONObject) { constructor(appData: IApp, connectionData: IJSONObject) {
this.connectionData = connectionData; this.connectionData = connectionData;
this.appData = appData; this.appData = appData;
this.client = new HttpClient({ baseURL: 'https://github.com' });
this.client = {
getWebFlowAuthorizationUrl,
exchangeWebFlowCode,
checkToken,
};
} }
get oauthRedirectUrl(): string { get oauthRedirectUrl(): string {
@@ -42,26 +26,28 @@ export default class Authentication implements IAuthentication {
} }
async createAuthData(): Promise<{ url: string }> { async createAuthData(): Promise<{ url: string }> {
const { url } = await this.client.getWebFlowAuthorizationUrl({ const searchParams = new URLSearchParams({
clientType: 'oauth-app', client_id: this.connectionData.consumerKey as string,
clientId: this.connectionData.consumerKey as string, redirect_uri: this.oauthRedirectUrl,
redirectUrl: this.oauthRedirectUrl, scope: this.scopes.join(','),
scopes: this.scopes,
}); });
const url = `https://github.com/login/oauth/authorize?${searchParams.toString()}`;
return { return {
url: url, url,
}; };
} }
async verifyCredentials() { async verifyCredentials() {
const { data } = await this.client.exchangeWebFlowCode({ const response = await this.client.post('/login/oauth/access_token', {
clientType: 'oauth-app', client_id: this.connectionData.consumerKey,
clientId: this.connectionData.consumerKey as string, client_secret: this.connectionData.consumerSecret,
clientSecret: this.connectionData.consumerSecret as string, code: this.connectionData.oauthVerifier,
code: this.connectionData.oauthVerifier as string,
}); });
const data = Object.fromEntries(new URLSearchParams(response.data));
this.connectionData.accessToken = data.access_token; this.connectionData.accessToken = data.access_token;
const tokenInfo = await this.getTokenInfo(); const tokenInfo = await this.getTokenInfo();
@@ -78,12 +64,23 @@ export default class Authentication implements IAuthentication {
} }
async getTokenInfo() { async getTokenInfo() {
return this.client.checkToken({ const basicAuthToken = Buffer.from(
clientType: 'oauth-app', this.connectionData.consumerKey + ':' + this.connectionData.consumerSecret
clientId: this.connectionData.consumerKey as string, ).toString('base64');
clientSecret: this.connectionData.consumerSecret as string,
token: this.connectionData.accessToken as string, const headers = {
}); Authorization: `Basic ${basicAuthToken}`,
};
const body = {
access_token: this.connectionData.accessToken,
};
return await this.client.post(
`https://api.github.com/applications/${this.connectionData.consumerKey}/token`,
body,
{ headers }
);
} }
async isStillVerified() { async isStillVerified() {

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/github/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/github/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/github", "docUrl": "https://automatisch.io/docs/github",
"primaryColor": "000000", "primaryColor": "000000",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",
@@ -19,26 +20,26 @@
}, },
{ {
"key": "consumerKey", "key": "consumerKey",
"label": "Consumer Key", "label": "Client ID",
"type": "string", "type": "string",
"required": true, "required": true,
"readOnly": false, "readOnly": false,
"value": null, "value": null,
"placeholder": null, "placeholder": null,
"description": null, "description": null,
"docUrl": "https://automatisch.io/docs/github#consumer-key", "docUrl": "https://automatisch.io/docs/github#client-id",
"clickToCopy": false "clickToCopy": false
}, },
{ {
"key": "consumerSecret", "key": "consumerSecret",
"label": "Consumer Secret", "label": "Client Secret",
"type": "string", "type": "string",
"required": true, "required": true,
"readOnly": false, "readOnly": false,
"value": null, "value": null,
"placeholder": null, "placeholder": null,
"description": null, "description": null,
"docUrl": "https://automatisch.io/docs/github#consumer-secret", "docUrl": "https://automatisch.io/docs/github#client-secret",
"clickToCopy": false "clickToCopy": false
} }
], ],
@@ -222,8 +223,8 @@
"description": "Triggers when a new repository is created", "description": "Triggers when a new repository is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "testStep", "key": "testStep",
@@ -237,8 +238,8 @@
"description": "Triggers when a new organization is created", "description": "Triggers when a new organization is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "testStep", "key": "testStep",
@@ -252,8 +253,8 @@
"description": "Triggers when a new branch is created", "description": "Triggers when a new branch is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -290,8 +291,8 @@
"description": "Triggers when a new notification is created", "description": "Triggers when a new notification is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -329,8 +330,8 @@
"description": "Triggers when a new pull request is created", "description": "Triggers when a new pull request is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -367,8 +368,8 @@
"description": "Triggers when a new watcher is added to a repo", "description": "Triggers when a new watcher is added to a repo",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -405,8 +406,8 @@
"description": "Triggers when a new milestone is created", "description": "Triggers when a new milestone is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -443,8 +444,8 @@
"description": "Triggers when a new commit comment is created", "description": "Triggers when a new commit comment is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -481,8 +482,8 @@
"description": "Triggers when a new label is created", "description": "Triggers when a new label is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -519,8 +520,8 @@
"description": "Triggers when a new collaborator is added to a repo", "description": "Triggers when a new collaborator is added to a repo",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -557,8 +558,8 @@
"description": "Triggers when a new release is created", "description": "Triggers when a new release is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -595,8 +596,8 @@
"description": "Triggers when a new commit is created", "description": "Triggers when a new commit is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -656,8 +657,8 @@
"description": "Triggers when a new issue is created", "description": "Triggers when a new issue is created",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/gitlab/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/gitlab/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/gitlab", "docUrl": "https://automatisch.io/docs/gitlab",
"primaryColor": "2DAAE1", "primaryColor": "2DAAE1",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/postgresql/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/postgresql/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/postgresql", "docUrl": "https://automatisch.io/docs/postgresql",
"primaryColor": "2DAAE1", "primaryColor": "2DAAE1",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "host", "key": "host",

View File

@@ -3,7 +3,9 @@
"key": "scheduler", "key": "scheduler",
"iconUrl": "{BASE_URL}/apps/scheduler/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/scheduler/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/scheduler", "docUrl": "https://automatisch.io/docs/scheduler",
"authDocUrl": "https://automatisch.io/docs/connections/scheduler",
"primaryColor": "0059F7", "primaryColor": "0059F7",
"supportsConnections": false,
"requiresAuthentication": false, "requiresAuthentication": false,
"triggers": [ "triggers": [
{ {

View File

@@ -1,13 +1,15 @@
import SendMessageToChannel from './actions/send-message-to-channel'; import SendMessageToChannel from './actions/send-message-to-channel';
import { IJSONObject } from '@automatisch/types'; import FindMessage from './actions/find-message';
import SlackClient from './client';
export default class Actions { export default class Actions {
client: SlackClient;
sendMessageToChannel: SendMessageToChannel; sendMessageToChannel: SendMessageToChannel;
findMessage: FindMessage;
constructor(connectionData: IJSONObject, parameters: IJSONObject) { constructor(client: SlackClient) {
this.sendMessageToChannel = new SendMessageToChannel( this.client = client;
connectionData, this.sendMessageToChannel = new SendMessageToChannel(client);
parameters this.findMessage = new FindMessage(client);
);
} }
} }

View File

@@ -0,0 +1,26 @@
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;
}
}

View File

@@ -1,21 +1,18 @@
import { WebClient } from '@slack/web-api'; import SlackClient from '../client';
import { IJSONObject } from '@automatisch/types';
export default class SendMessageToChannel { export default class SendMessageToChannel {
client: WebClient; client: SlackClient;
parameters: IJSONObject;
constructor(connectionData: IJSONObject, parameters: IJSONObject) { constructor(client: SlackClient) {
this.client = new WebClient(connectionData.accessToken as string); this.client = client;
this.parameters = parameters;
} }
async run() { async run() {
const result = await this.client.chat.postMessage({ const channelId = this.client.step.parameters.channel as string;
channel: this.parameters.channel as string, const text = this.client.step.parameters.message as string;
text: this.parameters.message as string,
});
return result; const message = await this.client.postMessageToChannel.run(channelId, text);
return message;
} }
} }

View File

@@ -1,36 +1,33 @@
import type { IAuthentication, IApp, IJSONObject } from '@automatisch/types'; import type { IAuthentication, IJSONObject } from '@automatisch/types';
import { WebClient } from '@slack/web-api'; import SlackClient from './client';
export default class Authentication implements IAuthentication { export default class Authentication implements IAuthentication {
appData: IApp; client: SlackClient;
connectionData: IJSONObject;
client: WebClient;
constructor(appData: IApp, connectionData: IJSONObject) { static requestOptions: IJSONObject = {
this.client = new WebClient(); headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
};
this.connectionData = connectionData; constructor(client: SlackClient) {
this.appData = appData; this.client = client;
} }
async verifyCredentials() { async verifyCredentials() {
const { bot_id: botId, user: screenName } = await this.client.auth.test({ const { bot_id: botId, user: screenName } =
token: this.connectionData.accessToken as string, await this.client.verifyAccessToken.run();
});
return { return {
botId, botId,
screenName, screenName,
token: this.connectionData.accessToken, token: this.client.connection.formattedData.accessToken,
}; };
} }
async isStillVerified() { async isStillVerified() {
try { try {
await this.client.auth.test({ await this.client.verifyAccessToken.run();
token: this.connectionData.accessToken as string,
});
return true; return true;
} catch (error) { } catch (error) {
return false; return false;

View File

@@ -0,0 +1,44 @@
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;
}
}

View File

@@ -0,0 +1,34 @@
import SlackClient from '../index';
export default class PostMessageToChannel {
client: SlackClient;
constructor(client: SlackClient) {
this.client = client;
}
async run(channelId: string, text: string) {
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 }
);
if (response.data.ok === 'false') {
throw new Error(
`Error occured while posting a message to channel: ${response.data.error}`
);
}
return response.data.message;
}
}

View File

@@ -0,0 +1,35 @@
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;
}
}

View File

@@ -0,0 +1,29 @@
import { IFlow, IStep, IConnection } from '@automatisch/types';
import HttpClient 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: HttpClient;
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 = new HttpClient({ baseURL: SlackClient.baseUrl });
this.verifyAccessToken = new VerifyAccessToken(this);
this.postMessageToChannel = new PostMessageToChannel(this);
this.findMessages = new FindMessages(this);
}
}

View File

@@ -1,10 +1,12 @@
import ListChannels from './data/list-channels'; import ListChannels from './data/list-channels';
import { IJSONObject } from '@automatisch/types'; import SlackClient from './client';
export default class Data { export default class Data {
client: SlackClient;
listChannels: ListChannels; listChannels: ListChannels;
constructor(connectionData: IJSONObject) { constructor(client: SlackClient) {
this.listChannels = new ListChannels(connectionData); this.client = client;
this.listChannels = new ListChannels(client);
} }
} }

View File

@@ -1,17 +1,27 @@
import type { IJSONObject } from '@automatisch/types'; import { IJSONObject } from '@automatisch/types';
import { WebClient } from '@slack/web-api'; import SlackClient from '../client';
export default class ListChannels { export default class ListChannels {
client: WebClient; client: SlackClient;
constructor(connectionData: IJSONObject) { constructor(client: SlackClient) {
this.client = new WebClient(connectionData.accessToken as string); this.client = client;
} }
async run() { async run() {
const { channels } = await this.client.conversations.list(); const response = await this.client.httpClient.get('/conversations.list', {
headers: {
Authorization: `Bearer ${this.client.connection.formattedData.accessToken}`,
},
});
return channels.map((channel) => { 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 { return {
value: channel.id, value: channel.id,
name: channel.name, name: channel.name,

View File

@@ -1,28 +1,30 @@
import { import {
IService, IService,
IAuthentication, IAuthentication,
IApp, IConnection,
IJSONObject, IFlow,
IStep,
} from '@automatisch/types'; } from '@automatisch/types';
import Authentication from './authentication'; import Authentication from './authentication';
import Triggers from './triggers'; import Triggers from './triggers';
import Actions from './actions'; import Actions from './actions';
import Data from './data'; import Data from './data';
import SlackClient from './client';
export default class Slack implements IService { export default class Slack implements IService {
client: SlackClient;
authenticationClient: IAuthentication; authenticationClient: IAuthentication;
triggers: Triggers; triggers: Triggers;
actions: Actions; actions: Actions;
data: Data; data: Data;
constructor( constructor(connection: IConnection, flow?: IFlow, step?: IStep) {
appData: IApp, this.client = new SlackClient(connection, flow, step);
connectionData: IJSONObject,
parameters: IJSONObject this.authenticationClient = new Authentication(this.client);
) { // this.triggers = new Triggers(this.client);
this.authenticationClient = new Authentication(appData, connectionData); this.actions = new Actions(this.client);
this.data = new Data(connectionData); this.data = new Data(this.client);
this.triggers = new Triggers(connectionData, parameters);
this.actions = new Actions(connectionData, parameters);
} }
} }

View File

@@ -3,7 +3,9 @@
"key": "slack", "key": "slack",
"iconUrl": "{BASE_URL}/apps/slack/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/slack/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/slack", "docUrl": "https://automatisch.io/docs/slack",
"authDocUrl": "https://automatisch.io/docs/connections/slack",
"primaryColor": "2DAAE1", "primaryColor": "2DAAE1",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "accessToken", "key": "accessToken",
@@ -14,7 +16,6 @@
"value": null, "value": null,
"placeholder": null, "placeholder": null,
"description": "Access token of slack that Automatisch will connect to.", "description": "Access token of slack that Automatisch will connect to.",
"docUrl": "https://automatisch.io/docs/slack#access-token",
"clickToCopy": false "clickToCopy": false
} }
], ],
@@ -101,11 +102,12 @@
{ {
"name": "New message posted to a channel", "name": "New message posted to a channel",
"key": "newMessageToChannel", "key": "newMessageToChannel",
"pollInterval": 15,
"description": "Triggers when a new message is posted to a channel", "description": "Triggers when a new message is posted to a channel",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -163,8 +165,8 @@
"description": "Send a message to a specific channel you specify.", "description": "Send a message to a specific channel you specify.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "setupAction", "key": "setupAction",
@@ -203,6 +205,73 @@
"name": "Test action" "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"
}
]
} }
] ]
} }

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/smtp/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/smtp/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/smtp", "docUrl": "https://automatisch.io/docs/smtp",
"primaryColor": "2DAAE1", "primaryColor": "2DAAE1",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "host", "key": "host",

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/twilio/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/twilio/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/twilio", "docUrl": "https://automatisch.io/docs/twilio",
"primaryColor": "f22f46", "primaryColor": "f22f46",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "accountSid", "key": "accountSid",

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/twitch/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/twitch/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/twitch", "docUrl": "https://automatisch.io/docs/twitch",
"primaryColor": "2DAAE1", "primaryColor": "2DAAE1",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",

View File

@@ -1,10 +1,12 @@
import TwitterClient from './client';
import CreateTweet from './actions/create-tweet'; import CreateTweet from './actions/create-tweet';
import { IJSONObject } from '@automatisch/types';
export default class Actions { export default class Actions {
client: TwitterClient;
createTweet: CreateTweet; createTweet: CreateTweet;
constructor(connectionData: IJSONObject, parameters: IJSONObject) { constructor(client: TwitterClient) {
this.createTweet = new CreateTweet(connectionData, parameters); this.client = client;
this.createTweet = new CreateTweet(client);
} }
} }

View File

@@ -1,23 +1,17 @@
import TwitterApi, { TwitterApiTokens } from 'twitter-api-v2'; import TwitterClient from '../client';
import { IJSONObject } from '@automatisch/types';
export default class CreateTweet { export default class CreateTweet {
client: TwitterApi; client: TwitterClient;
parameters: IJSONObject;
constructor(connectionData: IJSONObject, parameters: IJSONObject) { constructor(client: TwitterClient) {
this.client = new TwitterApi({ this.client = client;
appKey: connectionData.consumerKey,
appSecret: connectionData.consumerSecret,
accessToken: connectionData.accessToken,
accessSecret: connectionData.accessSecret,
} as TwitterApiTokens);
this.parameters = parameters;
} }
async run() { async run() {
const tweet = await this.client.v1.tweet(this.parameters.tweet as string); const tweet = await this.client.createTweet.run(
this.client.step.parameters.tweet as string
);
return tweet; return tweet;
} }
} }

View File

@@ -1,65 +1,50 @@
import type { import type { IAuthentication, IField } from '@automatisch/types';
IAuthentication, import { URLSearchParams } from 'url';
IApp, import TwitterClient from './client';
IField,
IJSONObject,
} from '@automatisch/types';
import TwitterApi, { TwitterApiTokens } from 'twitter-api-v2';
export default class Authentication implements IAuthentication { export default class Authentication implements IAuthentication {
appData: IApp; client: TwitterClient;
connectionData: IJSONObject;
client: TwitterApi;
constructor(appData: IApp, connectionData: IJSONObject) { constructor(client: TwitterClient) {
this.appData = appData; this.client = client;
this.connectionData = connectionData;
const clientParams = {
appKey: connectionData.consumerKey,
appSecret: connectionData.consumerSecret,
accessToken: connectionData.accessToken,
accessSecret: connectionData.accessSecret,
} as TwitterApiTokens;
this.client = new TwitterApi(clientParams);
} }
async createAuthData() { async createAuthData() {
const appFields = this.appData.fields.find( const appFields = this.client.connection.appData.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl' (field: IField) => field.key == 'oAuthRedirectUrl'
); );
const callbackUrl = appFields.value; const callbackUrl = appFields.value;
const authLink = await this.client.generateAuthLink(callbackUrl); const response = await this.client.oauthRequestToken.run(callbackUrl);
const responseData = Object.fromEntries(new URLSearchParams(response.data));
return { return {
url: authLink.url, url: `${TwitterClient.baseUrl}/oauth/authorize?oauth_token=${responseData.oauth_token}`,
accessToken: authLink.oauth_token, accessToken: responseData.oauth_token,
accessSecret: authLink.oauth_token_secret, accessSecret: responseData.oauth_token_secret,
}; };
} }
async verifyCredentials() { async verifyCredentials() {
const verifiedCredentials = await this.client.login( const response = await this.client.verifyAccessToken.run();
this.connectionData.oauthVerifier as string const responseData = Object.fromEntries(new URLSearchParams(response.data));
);
return { return {
consumerKey: this.connectionData.consumerKey, consumerKey: this.client.connection.formattedData.consumerKey as string,
consumerSecret: this.connectionData.consumerSecret, consumerSecret: this.client.connection.formattedData
accessToken: verifiedCredentials.accessToken, .consumerSecret as string,
accessSecret: verifiedCredentials.accessSecret, accessToken: responseData.oauth_token,
userId: verifiedCredentials.userId, accessSecret: responseData.oauth_token_secret,
screenName: verifiedCredentials.screenName, userId: responseData.user_id,
screenName: responseData.screen_name,
}; };
} }
async isStillVerified() { async isStillVerified() {
try { try {
await this.client.currentUser(); await this.client.getCurrentUser.run();
return true; return true;
} catch { } catch (error) {
return false; return false;
} }
} }

View File

@@ -0,0 +1,40 @@
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}`);
}
}
}

View File

@@ -0,0 +1,35 @@
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;
}
}

View File

@@ -0,0 +1,45 @@
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;
}
}

View File

@@ -0,0 +1,70 @@
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;
}
}

View File

@@ -0,0 +1,71 @@
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;
}
}

View File

@@ -0,0 +1,42 @@
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}`
);
}
}
}

View File

@@ -0,0 +1,70 @@
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: IJSONObject[] = [];
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 },
});
console.log(response);
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 errors = response.data.errors;
return { errors, data: tweets };
}
return { data: tweets };
}
}

View File

@@ -0,0 +1,20 @@
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);
}
}
}

View File

@@ -0,0 +1,64 @@
import { IFlow, IStep, IConnection } from '@automatisch/types';
import OAuth from 'oauth-1.0a';
import crypto from 'crypto';
import HttpClient 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: HttpClient;
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 = new HttpClient({ 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);
}
}

View File

@@ -1,25 +1,27 @@
import { import {
IService, IService,
IAuthentication, IAuthentication,
IApp, IFlow,
IJSONObject, IStep,
IConnection,
} from '@automatisch/types'; } from '@automatisch/types';
import Authentication from './authentication'; import Authentication from './authentication';
import Triggers from './triggers'; import Triggers from './triggers';
import Actions from './actions'; import Actions from './actions';
import TwitterClient from './client';
export default class Twitter implements IService { export default class Twitter implements IService {
client: TwitterClient;
authenticationClient: IAuthentication; authenticationClient: IAuthentication;
triggers: Triggers; triggers: Triggers;
actions: Actions; actions: Actions;
constructor( constructor(connection: IConnection, flow?: IFlow, step?: IStep) {
appData: IApp, this.client = new TwitterClient(connection, flow, step);
connectionData: IJSONObject,
parameters: IJSONObject this.authenticationClient = new Authentication(this.client);
) { this.triggers = new Triggers(this.client);
this.authenticationClient = new Authentication(appData, connectionData); this.actions = new Actions(this.client);
this.triggers = new Triggers(connectionData, parameters);
this.actions = new Actions(connectionData, parameters);
} }
} }

View File

@@ -3,7 +3,9 @@
"key": "twitter", "key": "twitter",
"iconUrl": "{BASE_URL}/apps/twitter/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/twitter/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/twitter", "docUrl": "https://automatisch.io/docs/twitter",
"authDocUrl": "https://automatisch.io/docs/connections/twitter",
"primaryColor": "2DAAE1", "primaryColor": "2DAAE1",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",
@@ -14,31 +16,28 @@
"value": "{WEB_APP_URL}/app/twitter/connections/add", "value": "{WEB_APP_URL}/app/twitter/connections/add",
"placeholder": null, "placeholder": null,
"description": "When asked to input an OAuth callback or redirect URL in Twitter OAuth, enter the URL above.", "description": "When asked to input an OAuth callback or redirect URL in Twitter OAuth, enter the URL above.",
"docUrl": "https://automatisch.io/docs/twitter#oauth-redirect-url",
"clickToCopy": true "clickToCopy": true
}, },
{ {
"key": "consumerKey", "key": "consumerKey",
"label": "Consumer Key", "label": "API Key",
"type": "string", "type": "string",
"required": true, "required": true,
"readOnly": false, "readOnly": false,
"value": null, "value": null,
"placeholder": null, "placeholder": null,
"description": null, "description": null,
"docUrl": "https://automatisch.io/docs/twitter#consumer-key",
"clickToCopy": false "clickToCopy": false
}, },
{ {
"key": "consumerSecret", "key": "consumerSecret",
"label": "Consumer Secret", "label": "API Secret",
"type": "string", "type": "string",
"required": true, "required": true,
"readOnly": false, "readOnly": false,
"value": null, "value": null,
"placeholder": null, "placeholder": null,
"description": null, "description": null,
"docUrl": "https://automatisch.io/docs/twitter#consumer-secret",
"clickToCopy": false "clickToCopy": false
} }
], ],
@@ -217,13 +216,14 @@
], ],
"triggers": [ "triggers": [
{ {
"name": "My Tweet", "name": "My Tweets",
"key": "myTweet", "key": "myTweets",
"pollInterval": 15,
"description": "Will be triggered when you tweet something new.", "description": "Will be triggered when you tweet something new.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "testStep", "key": "testStep",
@@ -232,13 +232,14 @@
] ]
}, },
{ {
"name": "User Tweet", "name": "User Tweets",
"key": "userTweet", "key": "userTweets",
"pollInterval": 15,
"description": "Will be triggered when a specific user tweet something new.", "description": "Will be triggered when a specific user tweet something new.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -259,13 +260,14 @@
] ]
}, },
{ {
"name": "Search Tweet", "name": "Search Tweets",
"key": "searchTweet", "key": "searchTweets",
"pollInterval": 15,
"description": "Will be triggered when any user tweet something containing a specific keyword, phrase, username or hashtag.", "description": "Will be triggered when any user tweet something containing a specific keyword, phrase, username or hashtag.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseTrigger", "key": "chooseTrigger",
@@ -284,6 +286,22 @@
"name": "Test trigger" "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": [ "actions": [
@@ -293,8 +311,8 @@
"description": "Will create a tweet.", "description": "Will create a tweet.",
"substeps": [ "substeps": [
{ {
"key": "chooseAccount", "key": "chooseConnection",
"name": "Choose account" "name": "Choose connection"
}, },
{ {
"key": "chooseAction", "key": "chooseAction",

View File

@@ -1,13 +1,21 @@
import { IJSONObject } from '@automatisch/types'; import TwitterClient from './client';
import MyTweet from './triggers/my-tweet'; import UserTweets from './triggers/user-tweets';
import SearchTweet from './triggers/search-tweet'; import SearchTweets from './triggers/search-tweets';
import MyTweets from './triggers/my-tweets';
import MyFollowers from './triggers/my-followers';
export default class Triggers { export default class Triggers {
myTweet: MyTweet; client: TwitterClient;
searchTweet: SearchTweet; userTweets: UserTweets;
searchTweets: SearchTweets;
myTweets: MyTweets;
myFollowers: MyFollowers;
constructor(connectionData: IJSONObject, parameters: IJSONObject) { constructor(client: TwitterClient) {
this.myTweet = new MyTweet(connectionData); this.client = client;
this.searchTweet = new SearchTweet(connectionData, parameters); this.userTweets = new UserTweets(client);
this.searchTweets = new SearchTweets(client);
this.myTweets = new MyTweets(client);
this.myFollowers = new MyFollowers(client);
} }
} }

View File

@@ -0,0 +1,28 @@
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;
}
}

View File

@@ -1,25 +0,0 @@
import TwitterApi, { TwitterApiTokens } from 'twitter-api-v2';
import { IJSONObject } from '@automatisch/types';
export default class MyTweet {
client: TwitterApi;
constructor(connectionData: IJSONObject) {
this.client = new TwitterApi({
appKey: connectionData.consumerKey,
appSecret: connectionData.consumerSecret,
accessToken: connectionData.accessToken,
accessSecret: connectionData.accessSecret,
} as TwitterApiTokens);
}
async run() {
const response = await this.client.currentUser();
const username = response.screen_name;
const userTimeline = await this.client.v1.userTimelineByUsername(username);
const fetchedTweets = userTimeline.tweets;
return fetchedTweets;
}
}

View File

@@ -0,0 +1,25 @@
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;
}
}

View File

@@ -1,58 +0,0 @@
import TwitterApi, { TwitterApiTokens } from 'twitter-api-v2';
import { IJSONObject } from '@automatisch/types';
export default class SearchTweet {
client: TwitterApi;
parameters: IJSONObject;
constructor(connectionData: IJSONObject, parameters: IJSONObject) {
this.client = new TwitterApi({
appKey: connectionData.consumerKey,
appSecret: connectionData.consumerSecret,
accessToken: connectionData.accessToken,
accessSecret: connectionData.accessSecret,
} as TwitterApiTokens);
this.parameters = parameters;
}
async run(startTime: Date) {
const tweets = [];
const response = await this.client.v2.search(
this.parameters.searchTerm as string,
{
max_results: 50,
'tweet.fields': 'created_at',
}
);
for await (const tweet of response.data.data) {
if (new Date(tweet.created_at).getTime() <= startTime.getTime()) {
break;
}
tweets.push(tweet);
if (response.data.meta.next_token) {
await response.fetchNext();
}
}
return tweets;
}
async testRun() {
const response = await this.client.v2.search(
this.parameters.searchTerm as string,
{
max_results: 10,
'tweet.fields': 'created_at',
}
);
const mostRecentTweet = response.data.data[0];
return [mostRecentTweet];
}
}

View File

@@ -0,0 +1,26 @@
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;
}
}

View File

@@ -0,0 +1,27 @@
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;
}
}

View File

@@ -5,14 +5,12 @@ import type {
IJSONObject, IJSONObject,
} from '@automatisch/types'; } from '@automatisch/types';
import { URLSearchParams } from 'url'; import { URLSearchParams } from 'url';
import axios, { AxiosInstance } from 'axios'; import HttpClient from '../../helpers/http-client';
export default class Authentication implements IAuthentication { export default class Authentication implements IAuthentication {
appData: IApp; appData: IApp;
connectionData: IJSONObject; connectionData: IJSONObject;
client: AxiosInstance = axios.create({ client: HttpClient;
baseURL: 'https://api.typeform.com',
});
scope: string[] = [ scope: string[] = [
'forms:read', 'forms:read',
@@ -27,6 +25,7 @@ export default class Authentication implements IAuthentication {
constructor(appData: IApp, connectionData: IJSONObject) { constructor(appData: IApp, connectionData: IJSONObject) {
this.connectionData = connectionData; this.connectionData = connectionData;
this.appData = appData; this.appData = appData;
this.client = new HttpClient({ baseURL: 'https://api.typeform.com' });
} }
get oauthRedirectUrl() { get oauthRedirectUrl() {

View File

@@ -4,6 +4,7 @@
"iconUrl": "{BASE_URL}/apps/typeform/assets/favicon.svg", "iconUrl": "{BASE_URL}/apps/typeform/assets/favicon.svg",
"docUrl": "https://automatisch.io/docs/typeform", "docUrl": "https://automatisch.io/docs/typeform",
"primaryColor": "5865f2", "primaryColor": "5865f2",
"supportsConnections": true,
"fields": [ "fields": [
{ {
"key": "oAuthRedirectUrl", "key": "oAuthRedirectUrl",

View File

@@ -13,6 +13,7 @@ type AppConfig = {
postgresHost: string; postgresHost: string;
postgresUsername: string; postgresUsername: string;
postgresPassword?: string; postgresPassword?: string;
version: string;
postgresEnableSsl: boolean; postgresEnableSsl: boolean;
baseUrl: string; baseUrl: string;
encryptionKey: string; encryptionKey: string;
@@ -20,12 +21,14 @@ type AppConfig = {
serveWebAppSeparately: boolean; serveWebAppSeparately: boolean;
redisHost: string; redisHost: string;
redisPort: number; redisPort: number;
enableBullMQDashboard: boolean;
}; };
const host = process.env.HOST || 'localhost'; const host = process.env.HOST || 'localhost';
const protocol = process.env.PROTOCOL || 'http'; const protocol = process.env.PROTOCOL || 'http';
const port = process.env.PORT || '3000'; const port = process.env.PORT || '3000';
const serveWebAppSeparately = process.env.SERVE_WEB_APP_SEPARATELY === 'true' ? true : false; const serveWebAppSeparately =
process.env.SERVE_WEB_APP_SEPARATELY === 'true' ? true : false;
let webAppUrl = `${protocol}://${host}:${port}`; let webAppUrl = `${protocol}://${host}:${port}`;
if (serveWebAppSeparately) { if (serveWebAppSeparately) {
@@ -42,8 +45,9 @@ const appConfig: AppConfig = {
port, port,
appEnv: appEnv, appEnv: appEnv,
isDev: appEnv === 'development', isDev: appEnv === 'development',
version: process.env.npm_package_version,
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development', postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresPort: parseInt(process.env.POSTGRES_PORT|| '5432'), postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
postgresHost: process.env.POSTGRES_HOST || 'localhost', postgresHost: process.env.POSTGRES_HOST || 'localhost',
postgresUsername: postgresUsername:
process.env.POSTGRES_USERNAME || 'automatisch_development_user', process.env.POSTGRES_USERNAME || 'automatisch_development_user',
@@ -54,6 +58,8 @@ const appConfig: AppConfig = {
serveWebAppSeparately, serveWebAppSeparately,
redisHost: process.env.REDIS_HOST || '127.0.0.1', redisHost: process.env.REDIS_HOST || '127.0.0.1',
redisPort: parseInt(process.env.REDIS_PORT || '6379'), redisPort: parseInt(process.env.REDIS_PORT || '6379'),
enableBullMQDashboard:
process.env.ENABLE_BULLMQ_DASHBOARD === 'true' ? true : false,
baseUrl, baseUrl,
webAppUrl, webAppUrl,
}; };

View File

@@ -1,4 +1,8 @@
import process from 'process'; import process from 'process';
// The following two lines are required to get count values as number.
// More info: https://github.com/knex/knex/issues/387#issuecomment-51554522
import pg from 'pg';
pg.types.setTypeParser(20, 'text', parseInt);
import knex from 'knex'; import knex from 'knex';
import type { Knex } from 'knex'; import type { Knex } from 'knex';
import knexConfig from '../../knexfile'; import knexConfig from '../../knexfile';
@@ -8,10 +12,12 @@ export const client: Knex = knex(knexConfig);
const CONNECTION_REFUSED = 'ECONNREFUSED'; const CONNECTION_REFUSED = 'ECONNREFUSED';
client.raw('SELECT 1') client.raw('SELECT 1').catch((err) => {
.catch((err) => { if (err.code === CONNECTION_REFUSED) {
if (err.code === CONNECTION_REFUSED) { logger.error(
logger.error('Make sure you have installed PostgreSQL and it is running.', err); 'Make sure you have installed PostgreSQL and it is running.',
process.exit(); err
} );
}); process.exit();
}
});

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.table('connections', (table) => {
table.boolean('draft').defaultTo(true);
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.table('connections', (table) => {
table.dropColumn('draft');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.table('flows', (table) => {
table.timestamp('published_at').nullable();
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.table('flows', (table) => {
table.dropColumn('published_at');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.table('executions', (table) => {
table.string('internal_id');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.table('executions', (table) => {
table.dropColumn('internal_id');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.table('execution_steps', (table) => {
table.jsonb('error_details');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.table('execution_steps', (table) => {
table.dropColumn('error_details');
});
}

View File

@@ -1,5 +1,5 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import App from '../../models/app'; import axios from 'axios';
type Params = { type Params = {
input: { input: {
@@ -20,13 +20,20 @@ const createAuthData = async (
.throwIfNotFound(); .throwIfNotFound();
const appClass = (await import(`../../apps/${connection.key}`)).default; const appClass = (await import(`../../apps/${connection.key}`)).default;
const appData = App.findOneByKey(connection.key);
if (!connection.formattedData) { return null; } if (!connection.formattedData) {
return null;
}
const appInstance = new appClass(appData, connection.formattedData); const appInstance = new appClass(connection);
const authLink = await appInstance.authenticationClient.createAuthData(); const authLink = await appInstance.authenticationClient.createAuthData();
try {
await axios.get(authLink.url);
} catch (error) {
throw new Error('Error occured while creating authorization URL!');
}
await connection.$query().patch({ await connection.$query().patch({
formattedData: { formattedData: {
...connection.formattedData, ...connection.formattedData,

View File

@@ -13,19 +13,12 @@ const createConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const app = App.findOneByKey(params.input.key); App.findOneByKey(params.input.key);
const connection = await context.currentUser return await context.currentUser.$relatedQuery('connections').insert({
.$relatedQuery('connections') key: params.input.key,
.insert({ formattedData: params.input.formattedData,
key: params.input.key, });
formattedData: params.input.formattedData,
});
return {
...connection,
app,
};
}; };
export default createConnection; export default createConnection;

View File

@@ -4,6 +4,7 @@ import Context from '../../types/express/context';
type Params = { type Params = {
input: { input: {
triggerAppKey: string; triggerAppKey: string;
connectionId: string;
}; };
}; };
@@ -12,17 +13,32 @@ const createFlow = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const connectionId = params?.input?.connectionId;
const appKey = params?.input?.triggerAppKey; const appKey = params?.input?.triggerAppKey;
const flow = await context.currentUser.$relatedQuery('flows').insert({ const flow = await context.currentUser.$relatedQuery('flows').insert({
name: 'Name your flow', name: 'Name your flow',
}); });
if (connectionId) {
await context.currentUser
.$relatedQuery('connections')
.findById(connectionId)
.throwIfNotFound();
}
await Step.query().insert({ await Step.query().insert({
flowId: flow.id, flowId: flow.id,
type: 'trigger', type: 'trigger',
position: 1, position: 1,
appKey, appKey,
connectionId
});
await Step.query().insert({
flowId: flow.id,
type: 'action',
position: 2
}); });
return flow; return flow;

View File

@@ -36,9 +36,13 @@ const updateFlowStatus = async (
const interval = trigger.interval; const interval = trigger.interval;
const repeatOptions = { const repeatOptions = {
cron: interval || EVERY_15_MINUTES_CRON, cron: interval || EVERY_15_MINUTES_CRON,
} };
if (flow.active) { if (flow.active) {
flow = await flow.$query().patchAndFetch({
published_at: new Date().toISOString(),
});
await processorQueue.add( await processorQueue.add(
JOB_NAME, JOB_NAME,
{ flowId: flow.id }, { flowId: flow.id },
@@ -49,7 +53,7 @@ const updateFlowStatus = async (
); );
} else { } else {
const repeatableJobs = await processorQueue.getRepeatableJobs(); const repeatableJobs = await processorQueue.getRepeatableJobs();
const job = repeatableJobs.find(job => job.id === flow.id); const job = repeatableJobs.find((job) => job.id === flow.id);
await processorQueue.removeRepeatableByKey(job.key); await processorQueue.removeRepeatableByKey(job.key);
} }

View File

@@ -20,9 +20,9 @@ const verifyConnection = async (
.throwIfNotFound(); .throwIfNotFound();
const appClass = (await import(`../../apps/${connection.key}`)).default; const appClass = (await import(`../../apps/${connection.key}`)).default;
const appData = App.findOneByKey(connection.key); const app = App.findOneByKey(connection.key);
const appInstance = new appClass(appData, connection.formattedData); const appInstance = new appClass(connection);
const verifiedCredentials = const verifiedCredentials =
await appInstance.authenticationClient.verifyCredentials(); await appInstance.authenticationClient.verifyCredentials();
@@ -32,9 +32,13 @@ const verifyConnection = async (
...verifiedCredentials, ...verifiedCredentials,
}, },
verified: true, verified: true,
draft: false,
}); });
return connection; return {
...connection,
app,
};
}; };
export default verifyConnection; export default verifyConnection;

View File

@@ -1,27 +0,0 @@
import App from '../../models/app';
import Context from '../../types/express/context';
type Params = {
key: string;
};
const getAppConnections = async (
_parent: unknown,
params: Params,
context: Context
) => {
const app = App.findOneByKey(params.key);
const connections = await context.currentUser
.$relatedQuery('connections')
.where({
key: params.key,
});
return connections.map((connection) => ({
...connection,
app,
}));
};
export default getAppConnections;

View File

@@ -11,9 +11,15 @@ const getApp = async (_parent: unknown, params: Params, context: Context) => {
if (context.currentUser) { if (context.currentUser) {
const connections = await context.currentUser const connections = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.select('connections.*')
.fullOuterJoinRelated('steps')
.where({ .where({
key: params.key, 'connections.key': params.key,
}); 'connections.draft': false,
})
.countDistinct('steps.flow_id as flowCount')
.groupBy('connections.id')
.orderBy('created_at', 'desc');
return { return {
...app, ...app,

View File

@@ -16,22 +16,42 @@ const getConnectedApps = async (
const connections = await context.currentUser const connections = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.select('connections.key') .select('connections.key')
.where({ draft: false })
.count('connections.id as count') .count('connections.id as count')
.where({ verified: true })
.groupBy('connections.key'); .groupBy('connections.key');
const flows = await context.currentUser
.$relatedQuery('flows')
.withGraphJoined('steps')
.orderBy('created_at', 'desc');
const duplicatedUsedApps = flows
.map((flow) => flow.steps.map((step) => step.appKey))
.flat()
.filter(Boolean);
const connectionKeys = connections.map((connection) => connection.key); const connectionKeys = connections.map((connection) => connection.key);
const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])];
apps = apps apps = apps
.filter((app: IApp) => connectionKeys.includes(app.key)) .filter((app: IApp) => {
return usedApps.includes(app.key);
})
.map((app: IApp) => { .map((app: IApp) => {
const connection = connections.find( const connection = connections.find(
(connection) => (connection as IConnection).key === app.key (connection) => (connection as IConnection).key === app.key
); );
if (connection) { app.connectionCount = connection?.count || 0;
app.connectionCount = connection.count; app.flowCount = 0;
}
flows.forEach((flow) => {
const usedFlow = flow.steps.find((step) => step.appKey === app.key);
if (usedFlow) {
app.flowCount += 1;
}
});
return app; return app;
}); });

View File

@@ -1,5 +1,4 @@
import { IJSONObject } from '@automatisch/types'; import { IJSONObject } from '@automatisch/types';
import App from '../../models/app';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
type Params = { type Params = {
@@ -11,7 +10,10 @@ type Params = {
const getData = async (_parent: unknown, params: Params, context: Context) => { const getData = async (_parent: unknown, params: Params, context: Context) => {
const step = await context.currentUser const step = await context.currentUser
.$relatedQuery('steps') .$relatedQuery('steps')
.withGraphFetched('connection') .withGraphFetched({
connection: true,
flow: true,
})
.findById(params.stepId); .findById(params.stepId);
if (!step) return null; if (!step) return null;
@@ -20,10 +22,9 @@ const getData = async (_parent: unknown, params: Params, context: Context) => {
if (!connection || !step.appKey) return null; if (!connection || !step.appKey) return null;
const appData = App.findOneByKey(step.appKey);
const AppClass = (await import(`../../apps/${step.appKey}`)).default; const AppClass = (await import(`../../apps/${step.appKey}`)).default;
const appInstance = new AppClass(connection, step.flow, step);
const appInstance = new AppClass(appData, connection.formattedData, params.parameters);
const command = appInstance.data[params.key]; const command = appInstance.data[params.key];
const fetchedData = await command.run(); const fetchedData = await command.run();

View File

@@ -0,0 +1,25 @@
import Context from '../../types/express/context';
type Params = {
executionId: string;
};
const getExecution = async (
_parent: unknown,
params: Params,
context: Context
) => {
const execution = await context.currentUser
.$relatedQuery('executions')
.withGraphFetched({
flow: {
steps: true
}
})
.findById(params.executionId)
.throwIfNotFound();
return execution;
};
export default getExecution;

View File

@@ -13,7 +13,11 @@ const getExecutions = async (
) => { ) => {
const executions = context.currentUser const executions = context.currentUser
.$relatedQuery('executions') .$relatedQuery('executions')
.withGraphFetched('flow') .withGraphFetched({
flow: {
steps: true
}
})
.orderBy('created_at', 'desc'); .orderBy('created_at', 'desc');
return paginate(executions, params.limit, params.offset); return paginate(executions, params.limit, params.offset);

View File

@@ -1,22 +1,42 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import paginate from '../../helpers/pagination';
type Params = { type Params = {
appKey?: string; appKey?: string;
connectionId?: string;
name?: string;
limit: number;
offset: number;
}; };
const getFlows = async (_parent: unknown, params: Params, context: Context) => { const getFlows = async (_parent: unknown, params: Params, context: Context) => {
const flowsQuery = context.currentUser const flowsQuery = context.currentUser
.$relatedQuery('flows') .$relatedQuery('flows')
.withGraphJoined('[steps.[connection]]') .joinRelated({
.orderBy('created_at', 'desc'); steps: true
})
.withGraphFetched({
steps: {
connection: true
}
})
.where((builder) => {
if (params.connectionId) {
builder.where('steps.connection_id', params.connectionId);
}
if (params.appKey) { if (params.name) {
flowsQuery.where('steps.app_key', params.appKey); builder.where('flows.name', 'ilike', `%${params.name}%`);
} }
const flows = await flowsQuery; if (params.appKey) {
builder.where('steps.app_key', params.appKey);
}
})
.groupBy('flows.id')
.orderBy('updated_at', 'desc');
return flows; return paginate(flowsQuery, params.limit, params.offset);
}; };
export default getFlows; export default getFlows;

View File

@@ -0,0 +1,9 @@
import appConfig from '../../config/app';
const healthcheck = () => {
return {
version: appConfig.version,
}
};
export default healthcheck;

View File

@@ -1,5 +1,4 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import App from '../../models/app';
type Params = { type Params = {
id: string; id: string;
@@ -19,9 +18,8 @@ const testConnection = async (
.throwIfNotFound(); .throwIfNotFound();
const appClass = (await import(`../../apps/${connection.key}`)).default; const appClass = (await import(`../../apps/${connection.key}`)).default;
const appData = App.findOneByKey(connection.key); const appInstance = new appClass(connection);
const appInstance = new appClass(appData, connection.formattedData);
const isStillVerified = const isStillVerified =
await appInstance.authenticationClient.isStillVerified(); await appInstance.authenticationClient.isStillVerified();

View File

@@ -1,29 +1,31 @@
import getApps from './queries/get-apps'; import getApps from './queries/get-apps';
import getApp from './queries/get-app'; import getApp from './queries/get-app';
import getConnectedApps from './queries/get-connected-apps'; import getConnectedApps from './queries/get-connected-apps';
import getAppConnections from './queries/get-app-connections';
import testConnection from './queries/test-connection'; import testConnection from './queries/test-connection';
import getFlow from './queries/get-flow'; import getFlow from './queries/get-flow';
import getFlows from './queries/get-flows'; import getFlows from './queries/get-flows';
import getStepWithTestExecutions from './queries/get-step-with-test-executions'; import getStepWithTestExecutions from './queries/get-step-with-test-executions';
import getExecution from './queries/get-execution';
import getExecutions from './queries/get-executions'; import getExecutions from './queries/get-executions';
import getExecutionSteps from './queries/get-execution-steps'; import getExecutionSteps from './queries/get-execution-steps';
import getData from './queries/get-data'; import getData from './queries/get-data';
import getCurrentUser from './queries/get-current-user'; import getCurrentUser from './queries/get-current-user';
import healthcheck from './queries/healthcheck';
const queryResolvers = { const queryResolvers = {
getApps, getApps,
getApp, getApp,
getConnectedApps, getConnectedApps,
getAppConnections,
testConnection, testConnection,
getFlow, getFlow,
getFlows, getFlows,
getStepWithTestExecutions, getStepWithTestExecutions,
getExecution,
getExecutions, getExecutions,
getExecutionSteps, getExecutionSteps,
getData, getData,
getCurrentUser, getCurrentUser,
healthcheck,
}; };
export default queryResolvers; export default queryResolvers;

View File

@@ -2,11 +2,17 @@ type Query {
getApps(name: String, onlyWithTriggers: Boolean): [App] getApps(name: String, onlyWithTriggers: Boolean): [App]
getApp(key: AvailableAppsEnumType!): App getApp(key: AvailableAppsEnumType!): App
getConnectedApps(name: String): [App] getConnectedApps(name: String): [App]
getAppConnections(key: AvailableAppsEnumType!): [Connection]
testConnection(id: String!): Connection testConnection(id: String!): Connection
getFlow(id: String!): Flow getFlow(id: String!): Flow
getFlows(appKey: String): [Flow] getFlows(
limit: Int!
offset: Int!
appKey: String
connectionId: String
name: String
): FlowConnection
getStepWithTestExecutions(stepId: String!): [Step] getStepWithTestExecutions(stepId: String!): [Step]
getExecution(executionId: String!): Execution
getExecutions(limit: Int!, offset: Int!): ExecutionConnection getExecutions(limit: Int!, offset: Int!): ExecutionConnection
getExecutionSteps( getExecutionSteps(
executionId: String! executionId: String!
@@ -15,6 +21,7 @@ type Query {
): ExecutionStepConnection ): ExecutionStepConnection
getData(stepId: String!, key: String!, parameters: JSONObject): JSONObject getData(stepId: String!, key: String!, parameters: JSONObject): JSONObject
getCurrentUser: User getCurrentUser: User
healthcheck: AppHealth
} }
type Mutation { type Mutation {
@@ -66,6 +73,7 @@ type ActionSubstepArgument {
description: String description: String
required: Boolean required: Boolean
variables: Boolean variables: Boolean
options: [ActionSubstepArgumentOption]
source: ActionSubstepArgumentSource source: ActionSubstepArgumentSource
dependsOn: [String] dependsOn: [String]
} }
@@ -76,6 +84,11 @@ type ActionSubstepArgumentSource {
arguments: [ActionSubstepArgumentSourceArgument] arguments: [ActionSubstepArgumentSourceArgument]
} }
type ActionSubstepArgumentOption {
label: String
value: JSONObject
}
type ActionSubstepArgumentSourceArgument { type ActionSubstepArgumentSourceArgument {
name: String name: String
value: String value: String
@@ -85,9 +98,12 @@ type App {
name: String name: String
key: String key: String
connectionCount: Int connectionCount: Int
flowCount: Int
iconUrl: String iconUrl: String
docUrl: String docUrl: String
authDocUrl: String
primaryColor: String primaryColor: String
supportsConnections: Boolean
fields: [Field] fields: [Field]
authenticationSteps: [AuthenticationStep] authenticationSteps: [AuthenticationStep]
reconnectionSteps: [ReconnectionStep] reconnectionSteps: [ReconnectionStep]
@@ -142,6 +158,7 @@ enum AvailableAppsEnumType {
twitter twitter
typeform typeform
slack slack
scheduler
} }
type Connection { type Connection {
@@ -151,6 +168,7 @@ type Connection {
verified: Boolean verified: Boolean
app: App app: App
createdAt: String createdAt: String
flowCount: Int
} }
type ConnectionData { type ConnectionData {
@@ -187,11 +205,22 @@ type Field {
clickToCopy: Boolean clickToCopy: Boolean
} }
type FlowConnection {
edges: [FlowEdge]
pageInfo: PageInfo
}
type FlowEdge {
node: Flow
}
type Flow { type Flow {
id: String id: String
name: String name: String
active: Boolean active: Boolean
steps: [Step] steps: [Step]
createdAt: String
updatedAt: String
} }
type Execution { type Execution {
@@ -230,6 +259,7 @@ input DeleteConnectionInput {
input CreateFlowInput { input CreateFlowInput {
triggerAppKey: String triggerAppKey: String
connectionId: String
} }
input UpdateFlowInput { input UpdateFlowInput {
@@ -319,6 +349,7 @@ type Step {
previousStepId: String previousStepId: String
key: String key: String
appKey: String appKey: String
iconUrl: String
type: StepEnumType type: StepEnumType
parameters: JSONObject parameters: JSONObject
connection: Connection connection: Connection
@@ -356,6 +387,7 @@ type Trigger {
name: String name: String
key: String key: String
description: String description: String
pollInterval: Int
substeps: [TriggerSubstep] substeps: [TriggerSubstep]
} }
@@ -423,6 +455,10 @@ type ExecutionStepConnection {
pageInfo: PageInfo pageInfo: PageInfo
} }
type AppHealth {
version: String
}
schema { schema {
query: Query query: Query
mutation: Mutation mutation: Mutation

View File

@@ -24,6 +24,7 @@ const authentication = shield(
{ {
Query: { Query: {
'*': isAuthenticated, '*': isAuthenticated,
healthcheck: allow,
}, },
Mutation: { Mutation: {
'*': isAuthenticated, '*': isAuthenticated,

View File

@@ -0,0 +1,21 @@
import axios, { AxiosInstance } from 'axios';
import { IJSONObject, IHttpClientParams } from '@automatisch/types';
export default class HttpClient {
instance: AxiosInstance;
constructor(params: IHttpClientParams) {
this.instance = axios.create({
baseURL: params.baseURL,
validateStatus: () => true,
});
}
async get(path: string, options?: IJSONObject) {
return await this.instance.get(path, options);
}
async post(path: string, body: IJSONObject | string, options?: IJSONObject) {
return await this.instance.post(path, body, options);
}
}

View File

@@ -125,7 +125,7 @@ class Telemetry {
diagnosticInfo() { diagnosticInfo() {
this.track('diagnosticInfo', { this.track('diagnosticInfo', {
automatischVersion: process.env.npm_package_version, automatischVersion: appConfig.version,
serveWebAppSeparately: appConfig.serveWebAppSeparately, serveWebAppSeparately: appConfig.serveWebAppSeparately,
operatingSystem: { operatingSystem: {
type: os.type(), type: os.type(),
@@ -139,7 +139,7 @@ class Telemetry {
}, },
}); });
setTimeout(this.diagnosticInfo, SIX_HOURS_IN_MILLISECONDS); setTimeout(() => this.diagnosticInfo(), SIX_HOURS_IN_MILLISECONDS);
} }
} }

View File

@@ -7,10 +7,15 @@ class App {
static folderPath = join(__dirname, '../apps'); static folderPath = join(__dirname, '../apps');
static list = fs.readdirSync(this.folderPath); static list = fs.readdirSync(this.folderPath);
static findAll(name?: string): IApp[] { // Temporaryly restrict the apps we expose until
if (!name) return this.list.map((name) => this.findOneByName(name)); // their actions/triggers are implemented!
static temporaryList = ['slack', 'twitter', 'scheduler'];
return this.list static findAll(name?: string): IApp[] {
if (!name)
return this.temporaryList.map((name) => this.findOneByName(name));
return this.temporaryList
.filter((app) => app.includes(name.toLowerCase())) .filter((app) => app.includes(name.toLowerCase()))
.map((name) => this.findOneByName(name)); .map((name) => this.findOneByName(name));
} }

View File

@@ -31,9 +31,9 @@ class Base extends Model {
} }
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise<void> { async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise<void> {
await super.$beforeUpdate(opt, queryContext);
this.updatedAt = new Date().toISOString(); this.updatedAt = new Date().toISOString();
await super.$beforeUpdate(opt, queryContext);
} }
} }

View File

@@ -3,6 +3,8 @@ import type { RelationMappings } from 'objection';
import { AES, enc } from 'crypto-js'; import { AES, enc } from 'crypto-js';
import Base from './base'; import Base from './base';
import User from './user'; import User from './user';
import Step from './step';
import App from './app';
import appConfig from '../config/app'; import appConfig from '../config/app';
import { IJSONObject } from '@automatisch/types'; import { IJSONObject } from '@automatisch/types';
import Telemetry from '../helpers/telemetry'; import Telemetry from '../helpers/telemetry';
@@ -14,7 +16,9 @@ class Connection extends Base {
formattedData?: IJSONObject; formattedData?: IJSONObject;
userId!: string; userId!: string;
verified = false; verified = false;
draft: boolean;
count?: number; count?: number;
flowCount?: number;
static tableName = 'connections'; static tableName = 'connections';
@@ -29,6 +33,7 @@ class Connection extends Base {
formattedData: { type: 'object' }, formattedData: { type: 'object' },
userId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' },
verified: { type: 'boolean' }, verified: { type: 'boolean' },
draft: { type: 'boolean' },
}, },
}; };
@@ -41,8 +46,20 @@ class Connection extends Base {
to: 'users.id', to: 'users.id',
}, },
}, },
steps: {
relation: Base.HasManyRelation,
modelClass: Step,
join: {
from: 'connections.id',
to: 'steps.connection_id',
},
},
}); });
get appData() {
return App.findOneByKey(this.key);
}
encryptData(): void { encryptData(): void {
if (!this.eligibleForEncryption()) return; if (!this.eligibleForEncryption()) return;

View File

@@ -10,6 +10,7 @@ class ExecutionStep extends Base {
stepId!: string; stepId!: string;
dataIn!: Record<string, unknown>; dataIn!: Record<string, unknown>;
dataOut!: Record<string, unknown>; dataOut!: Record<string, unknown>;
errorDetails: Record<string, unknown>;
status = 'failure'; status = 'failure';
step: Step; step: Step;
@@ -25,6 +26,7 @@ class ExecutionStep extends Base {
dataIn: { type: 'object' }, dataIn: { type: 'object' },
dataOut: { type: 'object' }, dataOut: { type: 'object' },
status: { type: 'string', enum: ['success', 'failure'] }, status: { type: 'string', enum: ['success', 'failure'] },
errorDetails: { type: ['object', 'null'] },
}, },
}; };

View File

@@ -8,6 +8,7 @@ class Execution extends Base {
id!: string; id!: string;
flowId!: string; flowId!: string;
testRun = false; testRun = false;
internalId: string;
executionSteps: ExecutionStep[] = []; executionSteps: ExecutionStep[] = [];
static tableName = 'executions'; static tableName = 'executions';
@@ -19,6 +20,7 @@ class Execution extends Base {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
flowId: { type: 'string', format: 'uuid' }, flowId: { type: 'string', format: 'uuid' },
testRun: { type: 'boolean' }, testRun: { type: 'boolean' },
internalId: { type: 'string' },
}, },
}; };

View File

@@ -1,5 +1,5 @@
import { ValidationError } from 'objection'; import { ValidationError } from 'objection';
import type { ModelOptions, QueryContext } from 'objection'; import type { ModelOptions, QueryContext, QueryBuilder } from 'objection';
import Base from './base'; import Base from './base';
import Step from './step'; import Step from './step';
import Execution from './execution'; import Execution from './execution';
@@ -9,17 +9,19 @@ class Flow extends Base {
id!: string; id!: string;
name!: string; name!: string;
userId!: string; userId!: string;
active = false; active: boolean;
steps?: [Step]; steps?: [Step];
published_at: string;
static tableName = 'flows'; static tableName = 'flows';
static jsonSchema = { static jsonSchema = {
type: 'object', type: 'object',
required: ['name'],
properties: { properties: {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
name: { type: 'string' }, name: { type: 'string', minLength: 1 },
userId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' },
active: { type: 'boolean' }, active: { type: 'boolean' },
}, },
@@ -33,6 +35,9 @@ class Flow extends Base {
from: 'flows.id', from: 'flows.id',
to: 'steps.flow_id', to: 'steps.flow_id',
}, },
filter(builder: QueryBuilder<Step>) {
builder.orderBy('position', 'asc');
},
}, },
executions: { executions: {
relation: Base.HasManyRelation, relation: Base.HasManyRelation,
@@ -44,7 +49,20 @@ class Flow extends Base {
}, },
}); });
async $beforeUpdate(opt: ModelOptions): Promise<void> { async lastInternalId() {
const lastExecution = await this.$relatedQuery('executions')
.orderBy('created_at', 'desc')
.first();
return lastExecution ? (lastExecution as Execution).internalId : null;
}
async $beforeUpdate(
opt: ModelOptions,
queryContext: QueryContext
): Promise<void> {
await super.$beforeUpdate(opt, queryContext);
if (!this.active) return; if (!this.active) return;
const oldFlow = opt.old as Flow; const oldFlow = opt.old as Flow;

View File

@@ -6,6 +6,7 @@ import Connection from './connection';
import ExecutionStep from './execution-step'; import ExecutionStep from './execution-step';
import type { IStep } from '@automatisch/types'; import type { IStep } from '@automatisch/types';
import Telemetry from '../helpers/telemetry'; import Telemetry from '../helpers/telemetry';
import appConfig from '../config/app';
class Step extends Base { class Step extends Base {
id!: string; id!: string;
@@ -40,6 +41,10 @@ class Step extends Base {
}, },
}; };
static get virtualAttributes() {
return ['iconUrl'];
}
static relationMappings = () => ({ static relationMappings = () => ({
flow: { flow: {
relation: Base.BelongsToOneRelation, relation: Base.BelongsToOneRelation,
@@ -67,6 +72,16 @@ class Step extends Base {
}, },
}); });
get iconUrl() {
if (!this.appKey) return null;
return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`;
}
get appData() {
return App.findOneByKey(this.appKey);
}
async $afterInsert(queryContext: QueryContext) { async $afterInsert(queryContext: QueryContext) {
await super.$afterInsert(queryContext); await super.$afterInsert(queryContext);
Telemetry.stepCreated(this); Telemetry.stepCreated(this);
@@ -84,15 +99,13 @@ class Step extends Base {
async getTrigger() { async getTrigger() {
if (!this.isTrigger) return null; if (!this.isTrigger) return null;
const { appKey, connection, key, parameters = {} } = this; const { appKey, key } = this;
const connection = await this.$relatedQuery('connection');
const flow = await this.$relatedQuery('flow');
const appData = App.findOneByKey(appKey);
const AppClass = (await import(`../apps/${appKey}`)).default; const AppClass = (await import(`../apps/${appKey}`)).default;
const appInstance = new AppClass( const appInstance = new AppClass(connection, flow, this);
appData,
connection?.formattedData,
parameters,
);
const command = appInstance.triggers[key]; const command = appInstance.triggers[key];
return command; return command;

View File

@@ -10,7 +10,11 @@ const redisConnection = {
}; };
const processorQueue = new Queue('processor', redisConnection); const processorQueue = new Queue('processor', redisConnection);
new QueueScheduler('processor', redisConnection); const queueScheduler = new QueueScheduler('processor', redisConnection);
process.on('SIGTERM', async () => {
await queueScheduler.close();
});
processorQueue.on('error', (err) => { processorQueue.on('error', (err) => {
if ((err as any).code === CONNECTION_REFUSED) { if ((err as any).code === CONNECTION_REFUSED) {

View File

@@ -1,9 +1,9 @@
import get from 'lodash.get'; import get from 'lodash.get';
import App from '../models/app';
import Flow from '../models/flow'; import Flow from '../models/flow';
import Step from '../models/step'; import Step from '../models/step';
import Execution from '../models/execution'; import Execution from '../models/execution';
import ExecutionStep from '../models/execution-step'; import ExecutionStep from '../models/execution-step';
import { IJSONObject } from '@automatisch/types';
type ExecutionSteps = Record<string, ExecutionStep>; type ExecutionSteps = Record<string, ExecutionStep>;
@@ -33,18 +33,44 @@ class Processor {
const triggerStep = steps.find((step) => step.type === 'trigger'); const triggerStep = steps.find((step) => step.type === 'trigger');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let initialTriggerData = await this.getInitialTriggerData(triggerStep!); const initialTriggerData = await this.getInitialTriggerData(triggerStep!);
if (initialTriggerData.data.length === 0) {
const lastInternalId = await this.flow.lastInternalId();
const executionData: Partial<Execution> = {
flowId: this.flow.id,
testRun: this.testRun,
};
if (lastInternalId) {
executionData.internalId = lastInternalId;
}
await Execution.query().insert(executionData);
return;
}
if (this.testRun) { if (this.testRun) {
initialTriggerData = [initialTriggerData[0]]; initialTriggerData.data = [initialTriggerData.data[0]];
}
if (initialTriggerData.data.length > 1) {
initialTriggerData.data = initialTriggerData.data.sort(
(item: IJSONObject, nextItem: IJSONObject) => {
return (item.id as number) - (nextItem.id as number);
}
);
} }
const executions: Execution[] = []; const executions: Execution[] = [];
for await (const data of initialTriggerData) { for await (const data of initialTriggerData.data) {
const execution = await Execution.query().insert({ const execution = await Execution.query().insert({
flowId: this.flow.id, flowId: this.flow.id,
testRun: this.testRun, testRun: this.testRun,
internalId: data.id,
}); });
executions.push(execution); executions.push(execution);
@@ -56,16 +82,7 @@ class Processor {
for await (const step of steps) { for await (const step of steps) {
if (!step.appKey) continue; if (!step.appKey) continue;
const appData = App.findOneByKey(step.appKey); const { appKey, key, type, parameters: rawParameters = {}, id } = step;
const {
appKey,
connection,
key,
type,
parameters: rawParameters = {},
id,
} = step;
const isTrigger = type === 'trigger'; const isTrigger = type === 'trigger';
const AppClass = (await import(`../apps/${appKey}`)).default; const AppClass = (await import(`../apps/${appKey}`)).default;
@@ -75,11 +92,9 @@ class Processor {
priorExecutionSteps priorExecutionSteps
); );
const appInstance = new AppClass( step.parameters = computedParameters;
appData,
connection?.formattedData, const appInstance = new AppClass(step.connection, this.flow, step);
computedParameters
);
if (!isTrigger && key) { if (!isTrigger && key) {
const command = appInstance.actions[key]; const command = appInstance.actions[key];
@@ -103,6 +118,23 @@ class Processor {
} }
} }
if (initialTriggerData.errors) {
const execution = await Execution.query().insert({
flowId: this.flow.id,
testRun: this.testRun,
internalId: null,
});
await ExecutionStep.query().insert({
executionId: execution.id,
stepId: steps[0].id,
status: 'failure',
dataIn: steps[0].parameters,
dataOut: null,
errorDetails: initialTriggerData.errors,
});
}
if (!this.testRun) return; if (!this.testRun) return;
const lastExecutionStepFromFirstExecution = await executions[0] const lastExecutionStepFromFirstExecution = await executions[0]
@@ -114,37 +146,21 @@ class Processor {
} }
async getInitialTriggerData(step: Step) { async getInitialTriggerData(step: Step) {
if (!step.appKey) return null; if (!step.appKey || !step.key) return null;
const appData = App.findOneByKey(step.appKey); const AppClass = (await import(`../apps/${step.appKey}`)).default;
const { appKey, connection, key, parameters: rawParameters = {} } = step; const appInstance = new AppClass(step.connection, this.flow, step);
if (!key) return null; const command = appInstance.triggers[step.key];
const AppClass = (await import(`../apps/${appKey}`)).default;
const appInstance = new AppClass(
appData,
connection?.formattedData,
rawParameters
);
const lastExecutionStep = await step
.$relatedQuery('executionSteps')
.orderBy('created_at', 'desc')
.first();
const lastExecutionStepCreatedAt = lastExecutionStep?.createdAt as string;
const flow = (await step.$relatedQuery('flow')) as Flow;
const command = appInstance.triggers[key];
const startTime = new Date(lastExecutionStepCreatedAt || flow.updatedAt);
let fetchedData; let fetchedData;
const lastInternalId = await this.flow.lastInternalId();
if (this.testRun) { if (this.testRun) {
fetchedData = await command.testRun(startTime); fetchedData = await command.testRun();
} else { } else {
fetchedData = await command.run(startTime); fetchedData = await command.run(lastInternalId);
} }
return fetchedData; return fetchedData;
@@ -163,7 +179,10 @@ class Processor {
.map((part: string) => { .map((part: string) => {
const isVariable = part.match(Processor.variableRegExp); const isVariable = part.match(Processor.variableRegExp);
if (isVariable) { if (isVariable) {
const stepIdAndKeyPath = part.replace(/{{step.|}}/g, '') as string; const stepIdAndKeyPath = part.replace(
/{{step.|}}/g,
''
) as string;
const [stepId, ...keyPaths] = stepIdAndKeyPath.split('.'); const [stepId, ...keyPaths] = stepIdAndKeyPath.split('.');
const keyPath = keyPaths.join('.'); const keyPath = keyPaths.join('.');
const executionStep = executionSteps[stepId.toString() as string]; const executionStep = executionSteps[stepId.toString() as string];

View File

@@ -1,2 +1,2 @@
import './config/orm'; import './config/orm';
import './workers/processor'; export { worker } from './workers/processor';

View File

@@ -4,7 +4,7 @@ import redisConfig from '../config/redis';
import Flow from '../models/flow'; import Flow from '../models/flow';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
const worker = new Worker( export const worker = new Worker(
'processor', 'processor',
async (job) => { async (job) => {
const flow = await Flow.query().findById(job.data.flowId).throwIfNotFound(); const flow = await Flow.query().findById(job.data.flowId).throwIfNotFound();
@@ -24,3 +24,7 @@ worker.on('failed', (job, err) => {
`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed with ${err.message}` `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed with ${err.message}`
); );
}); });
process.on('SIGTERM', async () => {
await worker.close();
});

1
packages/backend/worker.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export * from './dist/src/worker';

View File

@@ -0,0 +1,2 @@
/* eslint-disable */
module.exports = require('./dist/src/worker.js');

View File

@@ -0,0 +1,49 @@
import { readFileSync } from 'fs';
import { Command, Flags } from '@oclif/core';
import * as dotenv from 'dotenv';
export default class StartWorker extends Command {
static description = 'Run automatisch worker';
static flags = {
env: Flags.string({
multiple: true,
char: 'e',
}),
'env-file': Flags.string(),
}
async prepareEnvVars(): Promise<void> {
const { flags } = await this.parse(StartWorker);
if (flags['env-file']) {
const envFile = readFileSync(flags['env-file'], 'utf8');
const envConfig = dotenv.parse(envFile);
for (const key in envConfig) {
const value = envConfig[key];
process.env[key] = value;
}
}
if (flags.env) {
for (const env of flags.env) {
const [key, value] = env.split('=');
process.env[key] = value;
}
}
// must serve until more customization is introduced
delete process.env.SERVE_WEB_APP_SEPARATELY;
}
async runWorker(): Promise<void> {
await import('@automatisch/backend/worker');
}
async run(): Promise<void> {
await this.prepareEnvVars();
await this.runWorker();
}
}

View File

@@ -13,6 +13,10 @@ export default class Start extends Command {
'env-file': Flags.string(), 'env-file': Flags.string(),
} }
get isProduction() {
return process.env.APP_ENV === 'production';
}
async prepareEnvVars(): Promise<void> { async prepareEnvVars(): Promise<void> {
const { flags } = await this.parse(Start); const { flags } = await this.parse(Start);
@@ -38,7 +42,7 @@ export default class Start extends Command {
} }
async createDatabaseAndUser(): Promise<void> { async createDatabaseAndUser(): Promise<void> {
const { utils } = await import('@automatisch/backend/database'); const utils = await import('@automatisch/backend/database-utils');
await utils.createDatabaseAndUser( await utils.createDatabaseAndUser(
process.env.POSTGRES_DATABASE, process.env.POSTGRES_DATABASE,
@@ -48,7 +52,7 @@ export default class Start extends Command {
async runMigrationsIfNeeded(): Promise<void> { async runMigrationsIfNeeded(): Promise<void> {
const { logger } = await import('@automatisch/backend/logger'); const { logger } = await import('@automatisch/backend/logger');
const { database } = await import('@automatisch/backend/database'); const database = await import('@automatisch/backend/database');
const migrator = database.client.migrate; const migrator = database.client.migrate;
const [, pendingMigrations] = await migrator.list(); const [, pendingMigrations] = await migrator.list();
@@ -66,7 +70,7 @@ export default class Start extends Command {
} }
async seedUser(): Promise<void> { async seedUser(): Promise<void> {
const { utils } = await import('@automatisch/backend/database'); const utils = await import('@automatisch/backend/database-utils');
await utils.createUser(); await utils.createUser();
} }
@@ -78,7 +82,9 @@ export default class Start extends Command {
async run(): Promise<void> { async run(): Promise<void> {
await this.prepareEnvVars(); await this.prepareEnvVars();
await this.createDatabaseAndUser(); if (!this.isProduction) {
await this.createDatabaseAndUser();
}
await this.runMigrationsIfNeeded(); await this.runMigrationsIfNeeded();

View File

@@ -1,20 +0,0 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -1,33 +0,0 @@
# Website
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
```
$ GIT_USER=<Your GitHub username> USE_SSH=true yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

Some files were not shown because too many files have changed in this diff Show More