Merge branch 'main' into feature/signalwire-integration

This commit is contained in:
Ali BARIN
2023-03-13 21:14:14 +01:00
committed by GitHub
50 changed files with 527 additions and 76 deletions

View File

@@ -27,6 +27,8 @@
"@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",
"@rudderstack/rudder-sdk-node": "^1.1.2", "@rudderstack/rudder-sdk-node": "^1.1.2",
"@sentry/node": "^7.42.0",
"@sentry/tracing": "^7.42.0",
"@types/luxon": "^2.3.1", "@types/luxon": "^2.3.1",
"ajv-formats": "^2.1.1", "ajv-formats": "^2.1.1",
"axios": "0.24.0", "axios": "0.24.0",

View File

@@ -1,9 +1,12 @@
import createError from 'http-errors'; import createError from 'http-errors';
import express from 'express'; import express from 'express';
import appConfig from './config/app';
import cors from 'cors'; import cors from 'cors';
import { IRequest } from '@automatisch/types';
import appConfig from './config/app';
import corsOptions from './config/cors-options'; import corsOptions from './config/cors-options';
import morgan from './helpers/morgan'; import morgan from './helpers/morgan';
import * as Sentry from './helpers/sentry.ee';
import appAssetsHandler from './helpers/app-assets-handler'; import appAssetsHandler from './helpers/app-assets-handler';
import webUIHandler from './helpers/web-ui-handler'; import webUIHandler from './helpers/web-ui-handler';
import errorHandler from './helpers/error-handler'; import errorHandler from './helpers/error-handler';
@@ -14,12 +17,16 @@ 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';
import router from './routes'; import router from './routes';
import { IRequest } from '@automatisch/types';
createBullBoardHandler(serverAdapter); createBullBoardHandler(serverAdapter);
const app = express(); const app = express();
Sentry.init(app);
Sentry.attachRequestHandler(app);
Sentry.attachTracingHandler(app);
injectBullBoardHandler(app, serverAdapter); injectBullBoardHandler(app, serverAdapter);
appAssetsHandler(app); appAssetsHandler(app);
@@ -50,6 +57,8 @@ app.use(function (req, res, next) {
next(createError(404)); next(createError(404));
}); });
Sentry.attachErrorHandler(app);
app.use(errorHandler); app.use(errorHandler);
export default app; export default app;

View File

@@ -44,6 +44,7 @@ type AppConfig = {
stripeStarterPriceKey: string; stripeStarterPriceKey: string;
stripeGrowthPriceKey: string; stripeGrowthPriceKey: string;
licenseKey: string; licenseKey: string;
sentryDsn: string;
}; };
const host = process.env.HOST || 'localhost'; const host = process.env.HOST || 'localhost';
@@ -115,6 +116,7 @@ const appConfig: AppConfig = {
stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY, stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY,
stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY, stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY,
licenseKey: process.env.LICENSE_KEY, licenseKey: process.env.LICENSE_KEY,
sentryDsn: process.env.SENTRY_DSN,
}; };
if (!appConfig.encryptionKey) { if (!appConfig.encryptionKey) {

View File

@@ -1,5 +1,7 @@
import { Response } from 'express'; import { Response } from 'express';
import { IRequest } from '@automatisch/types'; import { IRequest } from '@automatisch/types';
import * as Sentry from '../../helpers/sentry.ee';
import Billing from '../../helpers/billing/index.ee'; import Billing from '../../helpers/billing/index.ee';
import appConfig from '../../config/app'; import appConfig from '../../config/app';
import logger from '../../helpers/logger'; import logger from '../../helpers/logger';
@@ -18,6 +20,8 @@ export default async (request: IRequest, response: Response) => {
return response.sendStatus(200); return response.sendStatus(200);
} catch (error) { } catch (error) {
logger.error(`Webhook Error: ${error.message}`); logger.error(`Webhook Error: ${error.message}`);
Sentry.captureException(error);
return response.sendStatus(400); return response.sendStatus(400);
} }
}; };

View File

@@ -14,6 +14,11 @@ export default async (request: IRequest, response: Response) => {
.throwIfNotFound(); .throwIfNotFound();
const testRun = !flow.active; const testRun = !flow.active;
if (!testRun) {
await flow.throwIfQuotaExceeded();
}
const triggerStep = await flow.getTriggerStep(); const triggerStep = await flow.getTriggerStep();
const triggerCommand = await triggerStep.getTriggerCommand(); const triggerCommand = await triggerStep.getTriggerCommand();
const app = await triggerStep.getApp(); const app = await triggerStep.getApp();

View File

@@ -2,6 +2,7 @@ import { IJSONObject } from '@automatisch/types';
export default class BaseError extends Error { export default class BaseError extends Error {
details = {}; details = {};
statusCode?: number;
constructor(error?: string | IJSONObject) { constructor(error?: string | IJSONObject) {
let computedError: Record<string, unknown>; let computedError: Record<string, unknown>;

View File

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

View File

@@ -1,3 +1,4 @@
import App from '../../models/app';
import Step from '../../models/step'; import Step from '../../models/step';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
@@ -16,15 +17,20 @@ const createFlow = async (
const connectionId = params?.input?.connectionId; const connectionId = params?.input?.connectionId;
const appKey = params?.input?.triggerAppKey; const appKey = params?.input?.triggerAppKey;
await App.findOneByKey(appKey);
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) { if (connectionId) {
await context.currentUser const hasConnection = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.findById(connectionId) .findById(connectionId);
.throwIfNotFound();
if (!hasConnection) {
throw new Error('The connection does not exist!');
}
} }
await Step.query().insert({ await Step.query().insert({

View File

@@ -1,3 +1,4 @@
import App from '../../models/app';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
type Params = { type Params = {
@@ -23,6 +24,14 @@ const createStep = async (
) => { ) => {
const { input } = params; 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 const flow = await context.currentUser
.$relatedQuery('flows') .$relatedQuery('flows')
.findOne({ .findOne({

View File

@@ -27,7 +27,7 @@ const createUser = async (_parent: unknown, params: Params) => {
}); });
if (appConfig.isCloud) { if (appConfig.isCloud) {
Billing.createSubscription(user); await Billing.createSubscription(user);
} }
return user; return user;

View File

@@ -13,11 +13,13 @@ const executeFlow = async (
context: Context context: Context
) => { ) => {
const { stepId } = params.input; const { stepId } = params.input;
const { executionStep } = await testRun({ stepId });
const untilStep = await context.currentUser const untilStep = await context.currentUser
.$relatedQuery('steps') .$relatedQuery('steps')
.findById(stepId); .findById(stepId)
.throwIfNotFound();
const { executionStep } = await testRun({ stepId });
if (executionStep.isFailed) { if (executionStep.isFailed) {
throw new Error(JSON.stringify(executionStep.errorDetails)); throw new Error(JSON.stringify(executionStep.errorDetails));

View File

@@ -1,4 +1,5 @@
import { IJSONObject } from '@automatisch/types'; import { IJSONObject } from '@automatisch/types';
import App from '../../models/app';
import Step from '../../models/step'; import Step from '../../models/step';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
@@ -32,6 +33,24 @@ const updateStep = async (
}) })
.throwIfNotFound(); .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() step = await Step.query()
.patchAndFetchById(input.id, { .patchAndFetchById(input.id, {
key: input.key, key: input.key,

View File

@@ -83,7 +83,7 @@ const createPaymentPortalUrl = async (user: User) => {
const userSession = await stripe.billingPortal.sessions.create({ const userSession = await stripe.billingPortal.sessions.create({
customer: paymentPlan.stripeCustomerId, customer: paymentPlan.stripeCustomerId,
return_url: 'https://cloud.automatisch.io', return_url: 'https://cloud.automatisch.io/settings/billing',
}); });
return userSession.url; return userSession.url;

View File

@@ -1,16 +1,15 @@
import { Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import logger from './logger'; import logger from './logger';
type Error = { import BaseError from '../errors/base';
message: string;
};
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') { if (err.message === 'Not Found') {
res.status(404).end(); res.status(404).end();
} else { } else {
logger.error(err.message); logger.error(err.message);
res.status(500).end(); res.status(err.statusCode || 500).send(err.message);
} }
}; };

View File

@@ -22,7 +22,11 @@ const apps = fs
return apps; return apps;
}, {} as TApps); }, {} 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; return (await apps[appKey]).default;
} }
@@ -31,7 +35,7 @@ function stripFunctions<C>(data: C): C {
} }
const getApp = async (appKey: string, stripFuncs = true) => { const getApp = async (appKey: string, stripFuncs = true) => {
let appData: IApp = cloneDeep(await getDefaultExport(appKey)); let appData: IApp = cloneDeep(await getAppDefaultExport(appKey));
if (appData.auth) { if (appData.auth) {
appData = addAuthenticationSteps(appData); appData = addAuthenticationSteps(appData);

View File

@@ -4,8 +4,10 @@ import { loadSchemaSync } from '@graphql-tools/load';
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
import { addResolversToSchema } from '@graphql-tools/schema'; import { addResolversToSchema } from '@graphql-tools/schema';
import { applyMiddleware } from 'graphql-middleware'; import { applyMiddleware } from 'graphql-middleware';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
import authentication from '../helpers/authentication'; import authentication from '../helpers/authentication';
import * as Sentry from '../helpers/sentry.ee';
import resolvers from '../graphql/resolvers'; import resolvers from '../graphql/resolvers';
import HttpError from '../errors/http'; import HttpError from '../errors/http';
@@ -28,6 +30,15 @@ const graphQLInstance = graphqlHTTP({
delete (error.originalError as HttpError).response; 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; return error;
}, },
}); });

View File

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

View File

@@ -36,6 +36,26 @@ class App {
return appInfoConverter(rawAppData); return appInfoConverter(rawAppData);
} }
static async checkAppAndAction(appKey: string, actionKey: string): Promise<void> {
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<void> {
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; export default App;

View File

@@ -1,11 +1,13 @@
import { ValidationError } from 'objection'; import { ValidationError } from 'objection';
import type { ModelOptions, QueryContext } from 'objection'; import type { ModelOptions, QueryContext } from 'objection';
import appConfig from '../config/app';
import ExtendedQueryBuilder from './query-builder'; import ExtendedQueryBuilder from './query-builder';
import Base from './base'; import Base from './base';
import Step from './step'; import Step from './step';
import User from './user'; import User from './user';
import Execution from './execution'; import Execution from './execution';
import Telemetry from '../helpers/telemetry'; import Telemetry from '../helpers/telemetry';
import QuotaExceededError from '../errors/quote-exceeded';
class Flow extends Base { class Flow extends Base {
id!: string; id!: string;
@@ -129,6 +131,33 @@ class Flow extends Base {
type: 'trigger', 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; export default Flow;

View File

@@ -1,12 +1,14 @@
import { raw } from 'objection'; import { raw } from 'objection';
import Base from './base'; import Base from './base';
import User from './user'; import User from './user';
import PaymentPlan from './payment-plan.ee';
class UsageData extends Base { class UsageData extends Base {
id!: string; id!: string;
userId!: string; userId!: string;
consumedTaskCount!: number; consumedTaskCount!: number;
nextResetAt!: string; nextResetAt!: string;
paymentPlan?: PaymentPlan;
static tableName = 'usage_data'; static tableName = 'usage_data';
@@ -31,8 +33,22 @@ class UsageData extends Base {
to: 'users.id', 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() { async increaseConsumedTaskCountByOne() {
return await this.$query().patch({ consumedTaskCount: raw('consumed_task_count + 1') }); return await this.$query().patch({ consumedTaskCount: raw('consumed_task_count + 1') });
} }

View File

@@ -1,5 +1,6 @@
import express, { Router } from 'express'; import express, { Response, Router, NextFunction, RequestHandler } from 'express';
import multer from 'multer'; import multer from 'multer';
import { IRequest } from '@automatisch/types'; import { IRequest } from '@automatisch/types';
import appConfig from '../config/app'; import appConfig from '../config/app';
import webhookHandler from '../controllers/webhooks/handler'; import webhookHandler from '../controllers/webhooks/handler';
@@ -16,9 +17,17 @@ router.use(express.text({
}, },
})); }));
router.get('/:flowId', webhookHandler); const exposeError = (handler: RequestHandler) => async (req: IRequest, res: Response, next: NextFunction) => {
router.put('/:flowId', webhookHandler); try {
router.patch('/:flowId', webhookHandler); await handler(req, res, next);
router.post('/:flowId', webhookHandler); } 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; export default router;

View File

@@ -17,17 +17,19 @@ type ProcessActionOptions = {
export const processAction = async (options: ProcessActionOptions) => { export const processAction = async (options: ProcessActionOptions) => {
const { flowId, stepId, executionId } = options; 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() const execution = await Execution.query()
.findById(executionId) .findById(executionId)
.throwIfNotFound(); .throwIfNotFound();
const step = await Step.query().findById(stepId).throwIfNotFound();
const $ = await globalVariable({ const $ = await globalVariable({
flow: await Flow.query().findById(flowId).throwIfNotFound(), flow,
app: await step.getApp(), app: await step.getApp(),
step: step, step: step,
connection: await step.$relatedQuery('connection'), connection: await step.$relatedQuery('connection'),
execution: execution, execution,
}); });
const priorExecutionSteps = await ExecutionStep.query().where({ const priorExecutionSteps = await ExecutionStep.query().where({

View File

@@ -10,8 +10,8 @@ type ProcessFlowOptions = {
}; };
export const processFlow = async (options: 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 triggerStep = await flow.getTriggerStep();
const triggerCommand = await triggerStep.getTriggerCommand(); const triggerCommand = await triggerStep.getTriggerCommand();
@@ -20,7 +20,7 @@ export const processFlow = async (options: ProcessFlowOptions) => {
connection: await triggerStep.$relatedQuery('connection'), connection: await triggerStep.$relatedQuery('connection'),
app: await triggerStep.getApp(), app: await triggerStep.getApp(),
step: triggerStep, step: triggerStep,
testRun: options.testRun, testRun,
}); });
try { try {

View File

@@ -1,3 +1,7 @@
import * as Sentry from './helpers/sentry.ee';
Sentry.init();
import './config/orm'; import './config/orm';
import './helpers/check-worker-readiness'; import './helpers/check-worker-readiness';
import './workers/flow'; import './workers/flow';

View File

@@ -1,4 +1,6 @@
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import * as Sentry from '../helpers/sentry.ee';
import redisConfig from '../config/redis'; import redisConfig from '../config/redis';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
import Step from '../models/step'; import Step from '../models/step';
@@ -65,6 +67,12 @@ worker.on('failed', (job, err) => {
logger.info( logger.info(
`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` `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 () => { process.on('SIGTERM', async () => {

View File

@@ -1,4 +1,6 @@
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import * as Sentry from '../helpers/sentry.ee';
import redisConfig from '../config/redis'; import redisConfig from '../config/redis';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
import User from '../models/user'; import User from '../models/user';
@@ -37,6 +39,12 @@ worker.on('failed', (job, err) => {
logger.info( logger.info(
`JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has failed to be deleted! ${err.message}` `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 () => { process.on('SIGTERM', async () => {

View File

@@ -1,4 +1,6 @@
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import * as Sentry from '../helpers/sentry.ee';
import redisConfig from '../config/redis'; import redisConfig from '../config/redis';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
import mailer from '../helpers/mailer.ee'; import mailer from '../helpers/mailer.ee';
@@ -30,6 +32,12 @@ worker.on('failed', (job, err) => {
logger.info( logger.info(
`JOB ID: ${job.id} - ${job.data.subject} email to ${job.data.email} has failed to send with ${err.message}` `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 () => { process.on('SIGTERM', async () => {

View File

@@ -1,4 +1,6 @@
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import * as Sentry from '../helpers/sentry.ee';
import redisConfig from '../config/redis'; import redisConfig from '../config/redis';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
import triggerQueue from '../queues/trigger'; import triggerQueue from '../queues/trigger';
@@ -12,6 +14,13 @@ export const worker = new Worker(
const { flowId } = job.data; const { flowId } = job.data;
const flow = await Flow.query().findById(flowId).throwIfNotFound(); const flow = await Flow.query().findById(flowId).throwIfNotFound();
const quotaExceeded = await flow.checkIfQuotaExceeded();
if (quotaExceeded) {
return;
}
const triggerStep = await flow.getTriggerStep(); const triggerStep = await flow.getTriggerStep();
const { data, error } = await processFlow({ flowId }); const { data, error } = await processFlow({ flowId });
@@ -58,6 +67,12 @@ worker.on('failed', (job, err) => {
logger.info( logger.info(
`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` `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 () => { process.on('SIGTERM', async () => {

View File

@@ -1,7 +1,9 @@
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import { IJSONObject, ITriggerItem } from '@automatisch/types';
import * as Sentry from '../helpers/sentry.ee';
import redisConfig from '../config/redis'; import redisConfig from '../config/redis';
import logger from '../helpers/logger'; import logger from '../helpers/logger';
import { IJSONObject, ITriggerItem } from '@automatisch/types';
import actionQueue from '../queues/action'; import actionQueue from '../queues/action';
import Step from '../models/step'; import Step from '../models/step';
import { processTrigger } from '../services/trigger'; import { processTrigger } from '../services/trigger';
@@ -51,6 +53,12 @@ worker.on('failed', (job, err) => {
logger.info( logger.info(
`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` `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 () => { process.on('SIGTERM', async () => {

View File

@@ -333,5 +333,6 @@ declare module 'axios' {
export interface IRequest extends Request { export interface IRequest extends Request {
rawBody?: Buffer; rawBody?: Buffer;
currentUser?: IUser;
} }

View File

@@ -12,6 +12,7 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import AccountDropdownMenu from 'components/AccountDropdownMenu'; import AccountDropdownMenu from 'components/AccountDropdownMenu';
import UsageAlert from 'components/UsageAlert/index.ee';
import Container from 'components/Container'; import Container from 'components/Container';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from './style'; import { Link } from './style';
@@ -29,9 +30,7 @@ export default function AppBar(props: AppBarProps): React.ReactElement {
const { drawerOpen, onDrawerOpen, onDrawerClose, maxWidth = false } = props; const { drawerOpen, onDrawerOpen, onDrawerClose, maxWidth = false } = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
noSsr: true,
});
const [accountMenuAnchorElement, setAccountMenuAnchorElement] = const [accountMenuAnchorElement, setAccountMenuAnchorElement] =
React.useState<null | HTMLElement>(null); React.useState<null | HTMLElement>(null);
@@ -83,6 +82,8 @@ export default function AppBar(props: AppBarProps): React.ReactElement {
</Toolbar> </Toolbar>
</Container> </Container>
<UsageAlert />
<AccountDropdownMenu <AccountDropdownMenu
anchorEl={accountMenuAnchorElement} anchorEl={accountMenuAnchorElement}
id={accountMenuId} id={accountMenuId}

View File

@@ -10,9 +10,7 @@ import { IconButton } from './style';
export default function ConditionalIconButton(props: any): React.ReactElement { export default function ConditionalIconButton(props: any): React.ReactElement {
const { icon, ...buttonProps } = props; const { icon, ...buttonProps } = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
noSsr: true,
});
if (matchSmallScreens) { if (matchSmallScreens) {
return ( return (

View File

@@ -31,9 +31,7 @@ type DrawerProps = {
export default function Drawer(props: DrawerProps): React.ReactElement { export default function Drawer(props: DrawerProps): React.ReactElement {
const { links = [], bottomLinks = [], ...drawerProps } = props; const { links = [], bottomLinks = [], ...drawerProps } = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
noSsr: true,
});
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const closeOnClick = (event: React.SyntheticEvent) => { const closeOnClick = (event: React.SyntheticEvent) => {

View File

@@ -52,9 +52,7 @@ export default function PublicLayout({
}: PublicLayoutProps): React.ReactElement { }: PublicLayoutProps): React.ReactElement {
const version = useVersion(); const version = useVersion();
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'));
noSsr: true,
});
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
const openDrawer = () => setDrawerOpen(true); const openDrawer = () => setDrawerOpen(true);

View File

@@ -1,4 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import appConfig from 'config/app'; import appConfig from 'config/app';
import useCurrentUser from 'hooks/useCurrentUser'; import useCurrentUser from 'hooks/useCurrentUser';
@@ -8,7 +10,9 @@ type ChatwootProps = {
} }
const Chatwoot = ({ ready }: ChatwootProps) => { const Chatwoot = ({ ready }: ChatwootProps) => {
const theme = useTheme();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
React.useEffect(function initiateChatwoot() { React.useEffect(function initiateChatwoot() {
window.chatwootSDK.run({ window.chatwootSDK.run({
@@ -30,8 +34,18 @@ const Chatwoot = ({ ready }: ChatwootProps) => {
name: currentUser.fullName, name: currentUser.fullName,
}); });
window.$chatwoot.toggleBubbleVisibility("show"); if (!matchSmallScreens) {
}, [currentUser, ready]); window.$chatwoot.toggleBubbleVisibility("show");
}
}, [currentUser, ready, matchSmallScreens]);
React.useLayoutEffect(function hideChatwoot() {
if (matchSmallScreens) {
window.$chatwoot?.toggleBubbleVisibility('hide');
} else {
window.$chatwoot?.toggleBubbleVisibility('show');
}
}, [matchSmallScreens])
return ( return (
<React.Fragment /> <React.Fragment />

View File

@@ -7,6 +7,7 @@ import Typography from '@mui/material/Typography';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import useAuthentication from 'hooks/useAuthentication'; import useAuthentication from 'hooks/useAuthentication';
import useCloud from 'hooks/useCloud';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import { LOGIN } from 'graphql/mutations/login'; import { LOGIN } from 'graphql/mutations/login';
import Form from 'components/Form'; import Form from 'components/Form';
@@ -14,6 +15,7 @@ import TextField from 'components/TextField';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
function LoginForm() { function LoginForm() {
const isCloud = useCloud();
const navigate = useNavigate(); const navigate = useNavigate();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const authentication = useAuthentication(); const authentication = useAuthentication();
@@ -76,13 +78,13 @@ function LoginForm() {
sx={{ mb: 1 }} sx={{ mb: 1 }}
/> />
<Link {isCloud &&<Link
component={RouterLink} component={RouterLink}
to={URLS.FORGOT_PASSWORD} to={URLS.FORGOT_PASSWORD}
underline="none" underline="none"
> >
{formatMessage('loginForm.forgotPasswordText')} {formatMessage('loginForm.forgotPasswordText')}
</Link> </Link>}
<LoadingButton <LoadingButton
type="submit" type="submit"
@@ -96,13 +98,13 @@ function LoginForm() {
{formatMessage('loginForm.submit')} {formatMessage('loginForm.submit')}
</LoadingButton> </LoadingButton>
<Typography variant="body1" align="center" mt={3}> {isCloud && <Typography variant="body1" align="center" mt={3}>
{formatMessage('loginForm.noAccount')} {formatMessage('loginForm.noAccount')}
&nbsp; &nbsp;
<Link component={RouterLink} to={URLS.SIGNUP} underline="none"> <Link component={RouterLink} to={URLS.SIGNUP} underline="none">
{formatMessage('loginForm.signUp')} {formatMessage('loginForm.signUp')}
</Link> </Link>
</Typography> </Typography>}
</Form> </Form>
</Paper> </Paper>
); );

View File

@@ -49,9 +49,7 @@ export default function SettingsLayout({
}: SettingsLayoutProps): React.ReactElement { }: SettingsLayoutProps): React.ReactElement {
const { isCloud } = useAutomatischInfo(); const { isCloud } = useAutomatischInfo();
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'));
noSsr: true,
});
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
const openDrawer = () => setDrawerOpen(true); const openDrawer = () => setDrawerOpen(true);

View File

@@ -40,8 +40,8 @@ function SignUpForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const authentication = useAuthentication(); const authentication = useAuthentication();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [createUser] = useMutation(CREATE_USER); const [createUser, { loading: createUserLoading }] = useMutation(CREATE_USER);
const [login, { loading }] = useMutation(LOGIN); const [login, { loading: loginLoading }] = useMutation(LOGIN);
React.useEffect(() => { React.useEffect(() => {
if (authentication.isAuthenticated) { if (authentication.isAuthenticated) {
@@ -165,7 +165,7 @@ function SignUpForm() {
variant="contained" variant="contained"
color="primary" color="primary"
sx={{ boxShadow: 2, mt: 3 }} sx={{ boxShadow: 2, mt: 3 }}
loading={loading} loading={createUserLoading || loginLoading}
fullWidth fullWidth
data-test="signUp-button" data-test="signUp-button"
> >

View File

@@ -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 (<React.Fragment />);
return (
<Snackbar
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
open
>
<Alert
icon={false}
sx={{ fontWeight: 500, minWidth: 410 }}
severity={usageAlert.hasExceededLimit ? 'error' : 'warning'}
>
<Stack direction="row" gap={4} mb={1}>
<Typography
variant="subtitle2"
sx={{ display: 'flex', alignItems: 'center' }}
>
{usageAlert.alertMessage}
</Typography>
<Button
size="small"
href={usageAlert.url}
sx={{ minWidth: 100 }}
>
{formatMessage('usageAlert.viewPlans')}
</Button>
</Stack>
<LinearProgress
variant="determinate"
value={usageAlert.consumptionPercentage}
/>
</Alert>
</Snackbar>
);
}

View File

@@ -7,6 +7,7 @@ const config: Config = {
graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string, graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string,
notificationsUrl: process.env.REACT_APP_NOTIFICATIONS_URL as string, notificationsUrl: process.env.REACT_APP_NOTIFICATIONS_URL as string,
chatwootBaseUrl: 'https://app.chatwoot.com', chatwootBaseUrl: 'https://app.chatwoot.com',
supportEmailAddress: 'support@automatisch.io'
}; };
export default config; export default config;

View File

@@ -1,7 +1,20 @@
import { useNavigate } from 'react-router-dom';
import useAutomatischInfo from './useAutomatischInfo'; 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 { isCloud } = useAutomatischInfo();
const navigate = useNavigate();
if (isCloud === false && redirect) {
navigate('/');
}
return isCloud; return isCloud;
} }

View File

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

View File

@@ -12,21 +12,23 @@ import routes from 'routes';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
ReactDOM.render( ReactDOM.render(
<SnackbarProvider> <Router>
<AuthenticationProvider> <SnackbarProvider>
<ApolloProvider> <AuthenticationProvider>
<AutomatischInfoProvider> <ApolloProvider>
<IntlProvider> <AutomatischInfoProvider>
<ThemeProvider> <IntlProvider>
<Router>{routes}</Router> <ThemeProvider>
{routes}
<LiveChat /> <LiveChat />
</ThemeProvider> </ThemeProvider>
</IntlProvider> </IntlProvider>
</AutomatischInfoProvider> </AutomatischInfoProvider>
</ApolloProvider> </ApolloProvider>
</AuthenticationProvider> </AuthenticationProvider>
</SnackbarProvider>, </SnackbarProvider>
</Router>,
document.getElementById('root') document.getElementById('root')
); );

View File

@@ -136,5 +136,7 @@
"resetPasswordForm.submit": "Reset password", "resetPasswordForm.submit": "Reset password",
"resetPasswordForm.passwordFieldLabel": "Password", "resetPasswordForm.passwordFieldLabel": "Password",
"resetPasswordForm.confirmPasswordFieldLabel": "Confirm 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"
}

View File

@@ -51,9 +51,7 @@ const ReconnectConnection = (props: any): React.ReactElement => {
export default function Application(): React.ReactElement | null { export default function Application(): React.ReactElement | null {
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
noSsr: true,
});
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const connectionsPathMatch = useMatch({ const connectionsPathMatch = useMatch({
path: URLS.APP_CONNECTIONS_PATTERN, path: URLS.APP_CONNECTIONS_PATTERN,

View File

@@ -1,9 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import useCloud from 'hooks/useCloud';
import Container from 'components/Container'; import Container from 'components/Container';
import ForgotPasswordForm from 'components/ForgotPasswordForm/index.ee'; import ForgotPasswordForm from 'components/ForgotPasswordForm/index.ee';
export default function ForgotPassword(): React.ReactElement { export default function ForgotPassword(): React.ReactElement {
useCloud({ redirect: true });
return ( return (
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
<Container maxWidth="sm"> <Container maxWidth="sm">

View File

@@ -1,9 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import useCloud from 'hooks/useCloud';
import Container from 'components/Container'; import Container from 'components/Container';
import ResetPasswordForm from 'components/ResetPasswordForm/index.ee'; import ResetPasswordForm from 'components/ResetPasswordForm/index.ee';
export default function ResetPassword(): React.ReactElement { export default function ResetPassword(): React.ReactElement {
useCloud({ redirect: true });
return ( return (
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
<Container maxWidth="sm"> <Container maxWidth="sm">

View File

@@ -1,9 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import useCloud from 'hooks/useCloud';
import Container from 'components/Container'; import Container from 'components/Container';
import SignUpForm from 'components/SignUpForm/index.ee'; import SignUpForm from 'components/SignUpForm/index.ee';
export default function Login(): React.ReactElement { export default function SignUp(): React.ReactElement {
useCloud({ redirect: true });
return ( return (
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
<Container maxWidth="sm"> <Container maxWidth="sm">

View File

@@ -251,6 +251,11 @@ const extendedTheme = createTheme({
}), }),
}, },
}, },
MuiUseMediaQuery: {
defaultProps: {
noSsr: true,
},
},
MuiTab: { MuiTab: {
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ root: ({ theme }) => ({

View File

@@ -3415,6 +3415,51 @@
component-type "^1.2.1" component-type "^1.2.1"
join-component "^1.1.0" 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": "@sindresorhus/is@^0.14.0":
version "0.14.0" version "0.14.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" 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" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
cookie@0.4.2: cookie@0.4.2, cookie@^0.4.1:
version "0.4.2" version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== 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" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.4.0.tgz#2830a779b483e9723e20f26fa5278463c50599d8"
integrity sha512-YOfuyWa/Ee+PXbDm40j9WXyJrzQUynVbgn4Km643UYcWNcrSfRkKL0WaiUcxcIbkXcVTgNpDqSnPXntWXT75cw== 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: luxon@2.5.2, luxon@^2.3.1:
version "2.5.2" version "2.5.2"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.5.2.tgz#17ed497f0277e72d58a4756d6a9abee4681457b6" 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-bom "^3.0.0"
strip-json-comments "^2.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" version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==