diff --git a/packages/backend/package.json b/packages/backend/package.json index f0875737..a3e2a4db 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -27,6 +27,8 @@ "@graphql-tools/graphql-file-loader": "^7.3.4", "@graphql-tools/load": "^7.5.2", "@rudderstack/rudder-sdk-node": "^1.1.2", + "@sentry/node": "^7.42.0", + "@sentry/tracing": "^7.42.0", "@types/luxon": "^2.3.1", "ajv-formats": "^2.1.1", "axios": "0.24.0", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e5413b01..e12d1c06 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,9 +1,12 @@ import createError from 'http-errors'; import express from 'express'; -import appConfig from './config/app'; import cors from 'cors'; + +import { IRequest } from '@automatisch/types'; +import appConfig from './config/app'; import corsOptions from './config/cors-options'; import morgan from './helpers/morgan'; +import * as Sentry from './helpers/sentry.ee'; import appAssetsHandler from './helpers/app-assets-handler'; import webUIHandler from './helpers/web-ui-handler'; import errorHandler from './helpers/error-handler'; @@ -14,12 +17,16 @@ import { } from './helpers/create-bull-board-handler'; import injectBullBoardHandler from './helpers/inject-bull-board-handler'; import router from './routes'; -import { IRequest } from '@automatisch/types'; createBullBoardHandler(serverAdapter); const app = express(); +Sentry.init(app); + +Sentry.attachRequestHandler(app); +Sentry.attachTracingHandler(app); + injectBullBoardHandler(app, serverAdapter); appAssetsHandler(app); @@ -50,6 +57,8 @@ app.use(function (req, res, next) { next(createError(404)); }); +Sentry.attachErrorHandler(app); + app.use(errorHandler); export default app; diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index efa5e8fa..fc8ad0b4 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -44,6 +44,7 @@ type AppConfig = { stripeStarterPriceKey: string; stripeGrowthPriceKey: string; licenseKey: string; + sentryDsn: string; }; const host = process.env.HOST || 'localhost'; @@ -115,6 +116,7 @@ const appConfig: AppConfig = { stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY, stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY, licenseKey: process.env.LICENSE_KEY, + sentryDsn: process.env.SENTRY_DSN, }; if (!appConfig.encryptionKey) { diff --git a/packages/backend/src/controllers/stripe/webhooks.ee.ts b/packages/backend/src/controllers/stripe/webhooks.ee.ts index fedbaea3..bd398dfe 100644 --- a/packages/backend/src/controllers/stripe/webhooks.ee.ts +++ b/packages/backend/src/controllers/stripe/webhooks.ee.ts @@ -1,5 +1,7 @@ import { Response } from 'express'; import { IRequest } from '@automatisch/types'; + +import * as Sentry from '../../helpers/sentry.ee'; import Billing from '../../helpers/billing/index.ee'; import appConfig from '../../config/app'; import logger from '../../helpers/logger'; @@ -18,6 +20,8 @@ export default async (request: IRequest, response: Response) => { return response.sendStatus(200); } catch (error) { logger.error(`Webhook Error: ${error.message}`); + + Sentry.captureException(error); return response.sendStatus(400); } }; diff --git a/packages/backend/src/controllers/webhooks/handler.ts b/packages/backend/src/controllers/webhooks/handler.ts index ed8684dd..5ac8a160 100644 --- a/packages/backend/src/controllers/webhooks/handler.ts +++ b/packages/backend/src/controllers/webhooks/handler.ts @@ -14,6 +14,11 @@ export default async (request: IRequest, response: Response) => { .throwIfNotFound(); const testRun = !flow.active; + + if (!testRun) { + await flow.throwIfQuotaExceeded(); + } + const triggerStep = await flow.getTriggerStep(); const triggerCommand = await triggerStep.getTriggerCommand(); const app = await triggerStep.getApp(); diff --git a/packages/backend/src/errors/base.ts b/packages/backend/src/errors/base.ts index 2e3e9863..e72fb6dc 100644 --- a/packages/backend/src/errors/base.ts +++ b/packages/backend/src/errors/base.ts @@ -2,6 +2,7 @@ import { IJSONObject } from '@automatisch/types'; export default class BaseError extends Error { details = {}; + statusCode?: number; constructor(error?: string | IJSONObject) { let computedError: Record; diff --git a/packages/backend/src/errors/quote-exceeded.ts b/packages/backend/src/errors/quote-exceeded.ts new file mode 100644 index 00000000..6a3239d0 --- /dev/null +++ b/packages/backend/src/errors/quote-exceeded.ts @@ -0,0 +1,9 @@ +import BaseError from './base'; + +export default class QuotaExceededError extends BaseError { + constructor(error = 'The allowed task quota has been exhausted!') { + super(error); + + this.statusCode = 422; + } +} diff --git a/packages/backend/src/graphql/mutations/create-flow.ts b/packages/backend/src/graphql/mutations/create-flow.ts index 5e015fe2..30c0968f 100644 --- a/packages/backend/src/graphql/mutations/create-flow.ts +++ b/packages/backend/src/graphql/mutations/create-flow.ts @@ -1,3 +1,4 @@ +import App from '../../models/app'; import Step from '../../models/step'; import Context from '../../types/express/context'; @@ -16,15 +17,20 @@ const createFlow = async ( const connectionId = params?.input?.connectionId; const appKey = params?.input?.triggerAppKey; + await App.findOneByKey(appKey); + const flow = await context.currentUser.$relatedQuery('flows').insert({ name: 'Name your flow', }); if (connectionId) { - await context.currentUser + const hasConnection = await context.currentUser .$relatedQuery('connections') - .findById(connectionId) - .throwIfNotFound(); + .findById(connectionId); + + if (!hasConnection) { + throw new Error('The connection does not exist!'); + } } await Step.query().insert({ diff --git a/packages/backend/src/graphql/mutations/create-step.ts b/packages/backend/src/graphql/mutations/create-step.ts index 80ba0a1c..7ee065d4 100644 --- a/packages/backend/src/graphql/mutations/create-step.ts +++ b/packages/backend/src/graphql/mutations/create-step.ts @@ -1,3 +1,4 @@ +import App from '../../models/app'; import Context from '../../types/express/context'; type Params = { @@ -23,6 +24,14 @@ const createStep = async ( ) => { const { input } = params; + if (input.appKey && input.key) { + await App.checkAppAndAction(input.appKey, input.key); + } + + if (input.appKey && !input.key) { + await App.findOneByKey(input.appKey); + } + const flow = await context.currentUser .$relatedQuery('flows') .findOne({ diff --git a/packages/backend/src/graphql/mutations/create-user.ee.ts b/packages/backend/src/graphql/mutations/create-user.ee.ts index a96828f1..3f875ce7 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.ts +++ b/packages/backend/src/graphql/mutations/create-user.ee.ts @@ -27,7 +27,7 @@ const createUser = async (_parent: unknown, params: Params) => { }); if (appConfig.isCloud) { - Billing.createSubscription(user); + await Billing.createSubscription(user); } return user; diff --git a/packages/backend/src/graphql/mutations/execute-flow.ts b/packages/backend/src/graphql/mutations/execute-flow.ts index 5caeaa77..6b25960b 100644 --- a/packages/backend/src/graphql/mutations/execute-flow.ts +++ b/packages/backend/src/graphql/mutations/execute-flow.ts @@ -13,11 +13,13 @@ const executeFlow = async ( context: Context ) => { const { stepId } = params.input; - const { executionStep } = await testRun({ stepId }); const untilStep = await context.currentUser .$relatedQuery('steps') - .findById(stepId); + .findById(stepId) + .throwIfNotFound(); + + const { executionStep } = await testRun({ stepId }); if (executionStep.isFailed) { throw new Error(JSON.stringify(executionStep.errorDetails)); diff --git a/packages/backend/src/graphql/mutations/update-step.ts b/packages/backend/src/graphql/mutations/update-step.ts index 0c5adbed..11398287 100644 --- a/packages/backend/src/graphql/mutations/update-step.ts +++ b/packages/backend/src/graphql/mutations/update-step.ts @@ -1,4 +1,5 @@ import { IJSONObject } from '@automatisch/types'; +import App from '../../models/app'; import Step from '../../models/step'; import Context from '../../types/express/context'; @@ -32,6 +33,24 @@ const updateStep = async ( }) .throwIfNotFound(); + if (input.connection.id) { + const hasConnection = await context.currentUser + .$relatedQuery('connections') + .findById(input.connection?.id); + + if (!hasConnection) { + throw new Error('The connection does not exist!'); + } + } + + if (step.isTrigger) { + await App.checkAppAndTrigger(input.appKey, input.key); + } + + if (step.isAction) { + await App.checkAppAndAction(input.appKey, input.key); + } + step = await Step.query() .patchAndFetchById(input.id, { key: input.key, diff --git a/packages/backend/src/helpers/billing/index.ee.ts b/packages/backend/src/helpers/billing/index.ee.ts index 56671078..3d11f58b 100644 --- a/packages/backend/src/helpers/billing/index.ee.ts +++ b/packages/backend/src/helpers/billing/index.ee.ts @@ -83,7 +83,7 @@ const createPaymentPortalUrl = async (user: User) => { const userSession = await stripe.billingPortal.sessions.create({ customer: paymentPlan.stripeCustomerId, - return_url: 'https://cloud.automatisch.io', + return_url: 'https://cloud.automatisch.io/settings/billing', }); return userSession.url; diff --git a/packages/backend/src/helpers/error-handler.ts b/packages/backend/src/helpers/error-handler.ts index a2871f1e..6c54c657 100644 --- a/packages/backend/src/helpers/error-handler.ts +++ b/packages/backend/src/helpers/error-handler.ts @@ -1,16 +1,15 @@ -import { Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; import logger from './logger'; -type Error = { - message: string; -}; +import BaseError from '../errors/base'; -const errorHandler = (err: Error, req: Request, res: Response): void => { +// Do not remove `next` argument as the function signature will not fit for an error handler middleware +const errorHandler = (err: BaseError, req: Request, res: Response, next: NextFunction): void => { if (err.message === 'Not Found') { res.status(404).end(); } else { logger.error(err.message); - res.status(500).end(); + res.status(err.statusCode || 500).send(err.message); } }; diff --git a/packages/backend/src/helpers/get-app.ts b/packages/backend/src/helpers/get-app.ts index 52f73894..436965d4 100644 --- a/packages/backend/src/helpers/get-app.ts +++ b/packages/backend/src/helpers/get-app.ts @@ -22,7 +22,11 @@ const apps = fs return apps; }, {} as TApps); -async function getDefaultExport(appKey: string) { +async function getAppDefaultExport(appKey: string) { + if (!Object.prototype.hasOwnProperty.call(apps, appKey)) { + throw new Error(`An application with the "${appKey}" key couldn't be found.`); + } + return (await apps[appKey]).default; } @@ -31,7 +35,7 @@ function stripFunctions(data: C): C { } const getApp = async (appKey: string, stripFuncs = true) => { - let appData: IApp = cloneDeep(await getDefaultExport(appKey)); + let appData: IApp = cloneDeep(await getAppDefaultExport(appKey)); if (appData.auth) { appData = addAuthenticationSteps(appData); diff --git a/packages/backend/src/helpers/graphql-instance.ts b/packages/backend/src/helpers/graphql-instance.ts index cace8043..34c92942 100644 --- a/packages/backend/src/helpers/graphql-instance.ts +++ b/packages/backend/src/helpers/graphql-instance.ts @@ -4,8 +4,10 @@ import { loadSchemaSync } from '@graphql-tools/load'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { addResolversToSchema } from '@graphql-tools/schema'; import { applyMiddleware } from 'graphql-middleware'; + import logger from '../helpers/logger'; import authentication from '../helpers/authentication'; +import * as Sentry from '../helpers/sentry.ee'; import resolvers from '../graphql/resolvers'; import HttpError from '../errors/http'; @@ -28,6 +30,15 @@ const graphQLInstance = graphqlHTTP({ delete (error.originalError as HttpError).response; } + Sentry.captureException(error, { + tags: { graphql: true }, + extra: { + source: error.source?.body, + positions: error.positions, + path: error.path + } + }) + return error; }, }); diff --git a/packages/backend/src/helpers/sentry.ee.ts b/packages/backend/src/helpers/sentry.ee.ts new file mode 100644 index 00000000..8369110e --- /dev/null +++ b/packages/backend/src/helpers/sentry.ee.ts @@ -0,0 +1,51 @@ +import { Express } from 'express'; +import * as Sentry from '@sentry/node'; +import type { CaptureContext } from '@sentry/types'; +import * as Tracing from '@sentry/tracing'; + +import appConfig from '../config/app'; + +export function init(app?: Express) { + if (!appConfig.isCloud) return; + + return Sentry.init({ + enabled: !!appConfig.sentryDsn, + dsn: appConfig.sentryDsn, + integrations: [ + app && new Sentry.Integrations.Http({ tracing: true }), + app && new Tracing.Integrations.Express({ app }), + app && new Tracing.Integrations.GraphQL(), + ].filter(Boolean), + tracesSampleRate: 1.0, + }); +} + + +export function attachRequestHandler(app: Express) { + if (!appConfig.isCloud) return; + + app.use(Sentry.Handlers.requestHandler()); +} + +export function attachTracingHandler(app: Express) { + if (!appConfig.isCloud) return; + + app.use(Sentry.Handlers.tracingHandler()); +} + +export function attachErrorHandler(app: Express) { + if (!appConfig.isCloud) return; + + app.use(Sentry.Handlers.errorHandler({ + shouldHandleError() { + // TODO: narrow down the captured errors in time as we receive samples + return true; + } + })); +} + +export function captureException(exception: any, captureContext?: CaptureContext) { + if (!appConfig.isCloud) return; + + return Sentry.captureException(exception, captureContext); +} diff --git a/packages/backend/src/models/app.ts b/packages/backend/src/models/app.ts index 998566a1..d50d3426 100644 --- a/packages/backend/src/models/app.ts +++ b/packages/backend/src/models/app.ts @@ -36,6 +36,26 @@ class App { return appInfoConverter(rawAppData); } + + static async checkAppAndAction(appKey: string, actionKey: string): Promise { + const app = await this.findOneByKey(appKey); + + const hasAction = app.actions?.find(action => action.key === actionKey); + + if (!hasAction) { + throw new Error(`${app.name} does not have an action with the "${actionKey}" key!`); + } + } + + static async checkAppAndTrigger(appKey: string, triggerKey: string): Promise { + const app = await this.findOneByKey(appKey); + + const hasTrigger = app.triggers?.find(trigger => trigger.key === triggerKey); + + if (!hasTrigger) { + throw new Error(`${app.name} does not have a trigger with the "${triggerKey}" key!`); + } + } } export default App; diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index e0868b97..6eaab50f 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -1,11 +1,13 @@ import { ValidationError } from 'objection'; import type { ModelOptions, QueryContext } from 'objection'; +import appConfig from '../config/app'; import ExtendedQueryBuilder from './query-builder'; import Base from './base'; import Step from './step'; import User from './user'; import Execution from './execution'; import Telemetry from '../helpers/telemetry'; +import QuotaExceededError from '../errors/quote-exceeded'; class Flow extends Base { id!: string; @@ -129,6 +131,33 @@ class Flow extends Base { type: 'trigger', }); } + + async checkIfQuotaExceeded() { + if (!appConfig.isCloud) return; + + const user = await this.$relatedQuery('user'); + const usageData = await user.$relatedQuery('usageData'); + + const hasExceeded = await usageData.checkIfLimitExceeded(); + + if (hasExceeded) { + return true; + } + + return false; + } + + async throwIfQuotaExceeded() { + if (!appConfig.isCloud) return; + + const hasExceeded = await this.checkIfQuotaExceeded(); + + if (hasExceeded) { + throw new QuotaExceededError(); + } + + return this; + } } export default Flow; diff --git a/packages/backend/src/models/usage-data.ee.ts b/packages/backend/src/models/usage-data.ee.ts index 558fe036..2e508f12 100644 --- a/packages/backend/src/models/usage-data.ee.ts +++ b/packages/backend/src/models/usage-data.ee.ts @@ -1,12 +1,14 @@ import { raw } from 'objection'; import Base from './base'; import User from './user'; +import PaymentPlan from './payment-plan.ee'; class UsageData extends Base { id!: string; userId!: string; consumedTaskCount!: number; nextResetAt!: string; + paymentPlan?: PaymentPlan; static tableName = 'usage_data'; @@ -31,8 +33,22 @@ class UsageData extends Base { to: 'users.id', }, }, + paymentPlan: { + relation: Base.BelongsToOneRelation, + modelClass: PaymentPlan, + join: { + from: 'usage_data.user_id', + to: 'payment_plans.user_id', + }, + }, }); + async checkIfLimitExceeded() { + const paymentPlan = await this.$relatedQuery('paymentPlan'); + + return this.consumedTaskCount >= paymentPlan.taskCount; + } + async increaseConsumedTaskCountByOne() { return await this.$query().patch({ consumedTaskCount: raw('consumed_task_count + 1') }); } diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts index cdf8f705..31e51297 100644 --- a/packages/backend/src/routes/webhooks.ts +++ b/packages/backend/src/routes/webhooks.ts @@ -1,5 +1,6 @@ -import express, { Router } from 'express'; +import express, { Response, Router, NextFunction, RequestHandler } from 'express'; import multer from 'multer'; + import { IRequest } from '@automatisch/types'; import appConfig from '../config/app'; import webhookHandler from '../controllers/webhooks/handler'; @@ -16,9 +17,17 @@ router.use(express.text({ }, })); -router.get('/:flowId', webhookHandler); -router.put('/:flowId', webhookHandler); -router.patch('/:flowId', webhookHandler); -router.post('/:flowId', webhookHandler); +const exposeError = (handler: RequestHandler) => async (req: IRequest, res: Response, next: NextFunction) => { + try { + await handler(req, res, next); + } catch (err) { + next(err); + } +} + +router.get('/:flowId', exposeError(webhookHandler)); +router.put('/:flowId', exposeError(webhookHandler)); +router.patch('/:flowId', exposeError(webhookHandler)); +router.post('/:flowId', exposeError(webhookHandler)); export default router; diff --git a/packages/backend/src/services/action.ts b/packages/backend/src/services/action.ts index f1d13373..0b1caf51 100644 --- a/packages/backend/src/services/action.ts +++ b/packages/backend/src/services/action.ts @@ -17,17 +17,19 @@ type ProcessActionOptions = { export const processAction = async (options: ProcessActionOptions) => { const { flowId, stepId, executionId } = options; - const step = await Step.query().findById(stepId).throwIfNotFound(); + const flow = await Flow.query().findById(flowId).throwIfNotFound(); const execution = await Execution.query() .findById(executionId) .throwIfNotFound(); + const step = await Step.query().findById(stepId).throwIfNotFound(); + const $ = await globalVariable({ - flow: await Flow.query().findById(flowId).throwIfNotFound(), + flow, app: await step.getApp(), step: step, connection: await step.$relatedQuery('connection'), - execution: execution, + execution, }); const priorExecutionSteps = await ExecutionStep.query().where({ diff --git a/packages/backend/src/services/flow.ts b/packages/backend/src/services/flow.ts index 9ff6d6fc..a0b97baf 100644 --- a/packages/backend/src/services/flow.ts +++ b/packages/backend/src/services/flow.ts @@ -10,8 +10,8 @@ type ProcessFlowOptions = { }; export const processFlow = async (options: ProcessFlowOptions) => { - const flow = await Flow.query().findById(options.flowId).throwIfNotFound(); - + const { testRun, flowId } = options; + const flow = await Flow.query().findById(flowId).throwIfNotFound(); const triggerStep = await flow.getTriggerStep(); const triggerCommand = await triggerStep.getTriggerCommand(); @@ -20,7 +20,7 @@ export const processFlow = async (options: ProcessFlowOptions) => { connection: await triggerStep.$relatedQuery('connection'), app: await triggerStep.getApp(), step: triggerStep, - testRun: options.testRun, + testRun, }); try { diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts index 9e0d5646..141ac78b 100644 --- a/packages/backend/src/worker.ts +++ b/packages/backend/src/worker.ts @@ -1,3 +1,7 @@ +import * as Sentry from './helpers/sentry.ee'; + +Sentry.init(); + import './config/orm'; import './helpers/check-worker-readiness'; import './workers/flow'; diff --git a/packages/backend/src/workers/action.ts b/packages/backend/src/workers/action.ts index 22d1169e..1cf16237 100644 --- a/packages/backend/src/workers/action.ts +++ b/packages/backend/src/workers/action.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import Step from '../models/step'; @@ -65,6 +67,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/delete-user.ee.ts b/packages/backend/src/workers/delete-user.ee.ts index 887149a2..12a0118f 100644 --- a/packages/backend/src/workers/delete-user.ee.ts +++ b/packages/backend/src/workers/delete-user.ee.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import User from '../models/user'; @@ -37,6 +39,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has failed to be deleted! ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/email.ts b/packages/backend/src/workers/email.ts index 953f9c41..ad8aba57 100644 --- a/packages/backend/src/workers/email.ts +++ b/packages/backend/src/workers/email.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import mailer from '../helpers/mailer.ee'; @@ -30,6 +32,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - ${job.data.subject} email to ${job.data.email} has failed to send with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/flow.ts b/packages/backend/src/workers/flow.ts index 7ca05782..f8b2b01b 100644 --- a/packages/backend/src/workers/flow.ts +++ b/packages/backend/src/workers/flow.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import triggerQueue from '../queues/trigger'; @@ -12,6 +14,13 @@ export const worker = new Worker( const { flowId } = job.data; const flow = await Flow.query().findById(flowId).throwIfNotFound(); + + const quotaExceeded = await flow.checkIfQuotaExceeded(); + + if (quotaExceeded) { + return; + } + const triggerStep = await flow.getTriggerStep(); const { data, error } = await processFlow({ flowId }); @@ -58,6 +67,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/trigger.ts b/packages/backend/src/workers/trigger.ts index a7492328..953bbf33 100644 --- a/packages/backend/src/workers/trigger.ts +++ b/packages/backend/src/workers/trigger.ts @@ -1,7 +1,9 @@ import { Worker } from 'bullmq'; + +import { IJSONObject, ITriggerItem } from '@automatisch/types'; +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; -import { IJSONObject, ITriggerItem } from '@automatisch/types'; import actionQueue from '../queues/action'; import Step from '../models/step'; import { processTrigger } from '../services/trigger'; @@ -51,6 +53,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 145c68a6..40ecea8e 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -333,5 +333,6 @@ declare module 'axios' { export interface IRequest extends Request { rawBody?: Buffer; + currentUser?: IUser; } diff --git a/packages/web/src/components/AppBar/index.tsx b/packages/web/src/components/AppBar/index.tsx index 56d52bd3..dfb4f4d1 100644 --- a/packages/web/src/components/AppBar/index.tsx +++ b/packages/web/src/components/AppBar/index.tsx @@ -12,6 +12,7 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import * as URLS from 'config/urls'; import AccountDropdownMenu from 'components/AccountDropdownMenu'; +import UsageAlert from 'components/UsageAlert/index.ee'; import Container from 'components/Container'; import { FormattedMessage } from 'react-intl'; import { Link } from './style'; @@ -29,9 +30,7 @@ export default function AppBar(props: AppBarProps): React.ReactElement { const { drawerOpen, onDrawerOpen, onDrawerClose, maxWidth = false } = props; const theme = useTheme(); - const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { - noSsr: true, - }); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); const [accountMenuAnchorElement, setAccountMenuAnchorElement] = React.useState(null); @@ -83,6 +82,8 @@ export default function AppBar(props: AppBarProps): React.ReactElement { + + { diff --git a/packages/web/src/components/Layout/index.tsx b/packages/web/src/components/Layout/index.tsx index 05e3838c..09c738de 100644 --- a/packages/web/src/components/Layout/index.tsx +++ b/packages/web/src/components/Layout/index.tsx @@ -52,9 +52,7 @@ export default function PublicLayout({ }: PublicLayoutProps): React.ReactElement { const version = useVersion(); const theme = useTheme(); - const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { - noSsr: true, - }); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const openDrawer = () => setDrawerOpen(true); diff --git a/packages/web/src/components/LiveChat/Chatwoot.ee.tsx b/packages/web/src/components/LiveChat/Chatwoot.ee.tsx index a45f7f3e..67182189 100644 --- a/packages/web/src/components/LiveChat/Chatwoot.ee.tsx +++ b/packages/web/src/components/LiveChat/Chatwoot.ee.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; import appConfig from 'config/app'; import useCurrentUser from 'hooks/useCurrentUser'; @@ -8,7 +10,9 @@ type ChatwootProps = { } const Chatwoot = ({ ready }: ChatwootProps) => { + const theme = useTheme(); const currentUser = useCurrentUser(); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); React.useEffect(function initiateChatwoot() { window.chatwootSDK.run({ @@ -30,8 +34,18 @@ const Chatwoot = ({ ready }: ChatwootProps) => { name: currentUser.fullName, }); - window.$chatwoot.toggleBubbleVisibility("show"); - }, [currentUser, ready]); + if (!matchSmallScreens) { + window.$chatwoot.toggleBubbleVisibility("show"); + } + }, [currentUser, ready, matchSmallScreens]); + + React.useLayoutEffect(function hideChatwoot() { + if (matchSmallScreens) { + window.$chatwoot?.toggleBubbleVisibility('hide'); + } else { + window.$chatwoot?.toggleBubbleVisibility('show'); + } + }, [matchSmallScreens]) return ( diff --git a/packages/web/src/components/LoginForm/index.tsx b/packages/web/src/components/LoginForm/index.tsx index 7070da10..1c30640a 100644 --- a/packages/web/src/components/LoginForm/index.tsx +++ b/packages/web/src/components/LoginForm/index.tsx @@ -7,6 +7,7 @@ import Typography from '@mui/material/Typography'; import LoadingButton from '@mui/lab/LoadingButton'; import useAuthentication from 'hooks/useAuthentication'; +import useCloud from 'hooks/useCloud'; import * as URLS from 'config/urls'; import { LOGIN } from 'graphql/mutations/login'; import Form from 'components/Form'; @@ -14,6 +15,7 @@ import TextField from 'components/TextField'; import useFormatMessage from 'hooks/useFormatMessage'; function LoginForm() { + const isCloud = useCloud(); const navigate = useNavigate(); const formatMessage = useFormatMessage(); const authentication = useAuthentication(); @@ -76,13 +78,13 @@ function LoginForm() { sx={{ mb: 1 }} /> - {formatMessage('loginForm.forgotPasswordText')} - + } - + {isCloud && {formatMessage('loginForm.noAccount')}   {formatMessage('loginForm.signUp')} - + } ); diff --git a/packages/web/src/components/SettingsLayout/index.tsx b/packages/web/src/components/SettingsLayout/index.tsx index 488349ea..ddeb90da 100644 --- a/packages/web/src/components/SettingsLayout/index.tsx +++ b/packages/web/src/components/SettingsLayout/index.tsx @@ -49,9 +49,7 @@ export default function SettingsLayout({ }: SettingsLayoutProps): React.ReactElement { const { isCloud } = useAutomatischInfo(); const theme = useTheme(); - const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { - noSsr: true, - }); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const openDrawer = () => setDrawerOpen(true); diff --git a/packages/web/src/components/SignUpForm/index.ee.tsx b/packages/web/src/components/SignUpForm/index.ee.tsx index 71070fcf..a9ef86ee 100644 --- a/packages/web/src/components/SignUpForm/index.ee.tsx +++ b/packages/web/src/components/SignUpForm/index.ee.tsx @@ -40,8 +40,8 @@ function SignUpForm() { const navigate = useNavigate(); const authentication = useAuthentication(); const formatMessage = useFormatMessage(); - const [createUser] = useMutation(CREATE_USER); - const [login, { loading }] = useMutation(LOGIN); + const [createUser, { loading: createUserLoading }] = useMutation(CREATE_USER); + const [login, { loading: loginLoading }] = useMutation(LOGIN); React.useEffect(() => { if (authentication.isAuthenticated) { @@ -165,7 +165,7 @@ function SignUpForm() { variant="contained" color="primary" sx={{ boxShadow: 2, mt: 3 }} - loading={loading} + loading={createUserLoading || loginLoading} fullWidth data-test="signUp-button" > diff --git a/packages/web/src/components/UsageAlert/index.ee.tsx b/packages/web/src/components/UsageAlert/index.ee.tsx new file mode 100644 index 00000000..ee6e391b --- /dev/null +++ b/packages/web/src/components/UsageAlert/index.ee.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import Alert from '@mui/material/Alert'; +import Snackbar from '@mui/material/Snackbar'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import LinearProgress from '@mui/material/LinearProgress'; + +import useFormatMessage from 'hooks/useFormatMessage'; +import useUsageAlert from 'hooks/useUsageAlert.ee'; + +export default function UsageAlert() { + const formatMessage = useFormatMessage(); + const usageAlert = useUsageAlert(); + + if (!usageAlert.showAlert) return (); + + return ( + + + + + {usageAlert.alertMessage} + + + + + + + + + ); +} diff --git a/packages/web/src/config/app.ts b/packages/web/src/config/app.ts index 184dbe2b..ea01596c 100644 --- a/packages/web/src/config/app.ts +++ b/packages/web/src/config/app.ts @@ -7,6 +7,7 @@ const config: Config = { graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string, notificationsUrl: process.env.REACT_APP_NOTIFICATIONS_URL as string, chatwootBaseUrl: 'https://app.chatwoot.com', + supportEmailAddress: 'support@automatisch.io' }; export default config; diff --git a/packages/web/src/hooks/useCloud.ts b/packages/web/src/hooks/useCloud.ts index 43c1360f..bb0226e7 100644 --- a/packages/web/src/hooks/useCloud.ts +++ b/packages/web/src/hooks/useCloud.ts @@ -1,7 +1,20 @@ +import { useNavigate } from 'react-router-dom'; + import useAutomatischInfo from './useAutomatischInfo'; -export default function useCloud(): boolean { +type UseCloudOptions = { + redirect?: boolean; +} + +export default function useCloud(options?: UseCloudOptions): boolean { + const redirect = options?.redirect || false; + const { isCloud } = useAutomatischInfo(); + const navigate = useNavigate(); + + if (isCloud === false && redirect) { + navigate('/'); + } return isCloud; } diff --git a/packages/web/src/hooks/useUsageAlert.ee.ts b/packages/web/src/hooks/useUsageAlert.ee.ts new file mode 100644 index 00000000..2bfd802a --- /dev/null +++ b/packages/web/src/hooks/useUsageAlert.ee.ts @@ -0,0 +1,46 @@ +import useFormatMessage from './useFormatMessage'; +import useUsageData from './useUsageData.ee'; +import usePaymentPortalUrl from './usePaymentPortalUrl.ee'; + +type UseUsageAlertReturn = { + showAlert: boolean; + hasExceededLimit?: boolean; + alertMessage?: string; + url?: string; + consumptionPercentage?: number; +}; + +export default function useUsageAlert(): UseUsageAlertReturn { + const { url, loading: paymentPortalUrlLoading } = usePaymentPortalUrl(); + const { + allowedTaskCount, + consumedTaskCount, + nextResetAt, + loading: usageDataLoading + } = useUsageData(); + const formatMessage = useFormatMessage(); + + if (paymentPortalUrlLoading || usageDataLoading) { + return { showAlert: false }; + } + + const hasLoaded = !paymentPortalUrlLoading || usageDataLoading; + const withinUsageThreshold = consumedTaskCount > allowedTaskCount * 0.7; + const consumptionPercentage = consumedTaskCount / allowedTaskCount * 100; + const showAlert = hasLoaded && withinUsageThreshold; + const hasExceededLimit = consumedTaskCount >= allowedTaskCount; + + const alertMessage = formatMessage('usageAlert.informationText', { + allowedTaskCount, + consumedTaskCount, + relativeResetDate: nextResetAt?.toRelative(), + }); + + return { + showAlert, + hasExceededLimit, + alertMessage, + consumptionPercentage, + url, + }; +} diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 502bd72e..c2cd7099 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -12,21 +12,23 @@ import routes from 'routes'; import reportWebVitals from './reportWebVitals'; ReactDOM.render( - - - - - - - {routes} + + + + + + + + {routes} - - - - - - - , + + + + + + + + , document.getElementById('root') ); diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index ddfda549..dee7e9cd 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -136,5 +136,7 @@ "resetPasswordForm.submit": "Reset password", "resetPasswordForm.passwordFieldLabel": "Password", "resetPasswordForm.confirmPasswordFieldLabel": "Confirm password", - "resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login." -} + "resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login.", + "usageAlert.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})", + "usageAlert.viewPlans": "View plans" +} \ No newline at end of file diff --git a/packages/web/src/pages/Application/index.tsx b/packages/web/src/pages/Application/index.tsx index f3db7ad9..2bee8da6 100644 --- a/packages/web/src/pages/Application/index.tsx +++ b/packages/web/src/pages/Application/index.tsx @@ -51,9 +51,7 @@ const ReconnectConnection = (props: any): React.ReactElement => { export default function Application(): React.ReactElement | null { const theme = useTheme(); - const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { - noSsr: true, - }); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); const formatMessage = useFormatMessage(); const connectionsPathMatch = useMatch({ path: URLS.APP_CONNECTIONS_PATTERN, diff --git a/packages/web/src/pages/ForgotPassword/index.ee.tsx b/packages/web/src/pages/ForgotPassword/index.ee.tsx index 4b75e703..1c1a27cf 100644 --- a/packages/web/src/pages/ForgotPassword/index.ee.tsx +++ b/packages/web/src/pages/ForgotPassword/index.ee.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; import Box from '@mui/material/Box'; + +import useCloud from 'hooks/useCloud'; import Container from 'components/Container'; import ForgotPasswordForm from 'components/ForgotPasswordForm/index.ee'; export default function ForgotPassword(): React.ReactElement { + useCloud({ redirect: true }); + return ( diff --git a/packages/web/src/pages/ResetPassword/index.ee.tsx b/packages/web/src/pages/ResetPassword/index.ee.tsx index eb41213e..8dfb0664 100644 --- a/packages/web/src/pages/ResetPassword/index.ee.tsx +++ b/packages/web/src/pages/ResetPassword/index.ee.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; import Box from '@mui/material/Box'; + +import useCloud from 'hooks/useCloud'; import Container from 'components/Container'; import ResetPasswordForm from 'components/ResetPasswordForm/index.ee'; export default function ResetPassword(): React.ReactElement { + useCloud({ redirect: true }); + return ( diff --git a/packages/web/src/pages/SignUp/index.ee.tsx b/packages/web/src/pages/SignUp/index.ee.tsx index afe51ddb..7d314164 100644 --- a/packages/web/src/pages/SignUp/index.ee.tsx +++ b/packages/web/src/pages/SignUp/index.ee.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; import Box from '@mui/material/Box'; + +import useCloud from 'hooks/useCloud'; import Container from 'components/Container'; import SignUpForm from 'components/SignUpForm/index.ee'; -export default function Login(): React.ReactElement { +export default function SignUp(): React.ReactElement { + useCloud({ redirect: true }); + return ( diff --git a/packages/web/src/styles/theme.ts b/packages/web/src/styles/theme.ts index 4624bb5b..11a8fe7c 100644 --- a/packages/web/src/styles/theme.ts +++ b/packages/web/src/styles/theme.ts @@ -251,6 +251,11 @@ const extendedTheme = createTheme({ }), }, }, + MuiUseMediaQuery: { + defaultProps: { + noSsr: true, + }, + }, MuiTab: { styleOverrides: { root: ({ theme }) => ({ diff --git a/yarn.lock b/yarn.lock index 742a7a0b..a13a021b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3415,6 +3415,51 @@ component-type "^1.2.1" join-component "^1.1.0" +"@sentry/core@7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.42.0.tgz#3333a1b868e8e69b6fbc8e5a9e9281be49321ac7" + integrity sha512-vNcTyoQz5kUXo5vMGDyc5BJMO0UugPvMfYMQVxqt/BuDNR30LVhY+DL2tW1DFZDvRvyn5At+H7kSTj6GFrANXQ== + dependencies: + "@sentry/types" "7.42.0" + "@sentry/utils" "7.42.0" + tslib "^1.9.3" + +"@sentry/node@^7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.42.0.tgz#62b31f5b5b8ffb8f2f917deb143e27935b357409" + integrity sha512-mmpVSDeoM5aEbKOMq3Wt54wAvH53bkivhRh3Ip+R7Uj3aOKkcVJST2XlbghHgoYQXTWz+pl475EVyODWgY9QYg== + dependencies: + "@sentry/core" "7.42.0" + "@sentry/types" "7.42.0" + "@sentry/utils" "7.42.0" + cookie "^0.4.1" + https-proxy-agent "^5.0.0" + lru_map "^0.3.3" + tslib "^1.9.3" + +"@sentry/tracing@^7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.42.0.tgz#bcdac21e1cb5f786465e6252625bd4bf0736e631" + integrity sha512-0veGu3Ntweuj1pwWrJIFHmVdow4yufCreGIhsNDyrclwOjaTY3uI8iA6N62+hhtxOvqv+xueB98K1DvT5liPCQ== + dependencies: + "@sentry/core" "7.42.0" + "@sentry/types" "7.42.0" + "@sentry/utils" "7.42.0" + tslib "^1.9.3" + +"@sentry/types@7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.42.0.tgz#e5019cd41a8c4a98c296e2ff28d6adab4b2eb14e" + integrity sha512-Ga0xaBIR/peuXQ88hI9a5TNY3GLNoH8jpsgPaAjAtRHkLsTx0y3AR+PrD7pUysza9QjvG+Qux01DRvLgaNKOHA== + +"@sentry/utils@7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.42.0.tgz#fcffd0404836cb56975fbef9e889a42cc55de596" + integrity sha512-cBiDZVipC+is+IVgsTQLJyZWUZQxlLZ9GarNT+XZOZ5BFh0acFtz88hO6+S7vGmhcx2aCvsdC9yb2Yf+BphK6Q== + dependencies: + "@sentry/types" "7.42.0" + tslib "^1.9.3" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -6778,7 +6823,7 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.4.2: +cookie@0.4.2, cookie@^0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -11703,6 +11748,11 @@ lru-cache@^7.3.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.4.0.tgz#2830a779b483e9723e20f26fa5278463c50599d8" integrity sha512-YOfuyWa/Ee+PXbDm40j9WXyJrzQUynVbgn4Km643UYcWNcrSfRkKL0WaiUcxcIbkXcVTgNpDqSnPXntWXT75cw== +lru_map@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" + integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ== + luxon@2.5.2, luxon@^2.3.1: version "2.5.2" resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.5.2.tgz#17ed497f0277e72d58a4756d6a9abee4681457b6" @@ -16799,7 +16849,7 @@ tsconfig@^7.0.0: strip-bom "^3.0.0" strip-json-comments "^2.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==