6658 workflows add a first twenty piece email sender (#6965)

This commit is contained in:
martmull
2024-09-12 11:00:25 +02:00
committed by GitHub
parent f8e5b333d9
commit 3190f4a87b
397 changed files with 1143 additions and 1037 deletions

View File

@ -7,9 +7,9 @@ import { AISQLQueryResolver } from 'src/engine/core-modules/ai-sql-query/ai-sql-
import { AISQLQueryService } from 'src/engine/core-modules/ai-sql-query/ai-sql-query.service';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { LLMChatModelModule } from 'src/engine/integrations/llm-chat-model/llm-chat-model.module';
import { EnvironmentModule } from 'src/engine/integrations/environment/environment.module';
import { LLMTracingModule } from 'src/engine/integrations/llm-tracing/llm-tracing.module';
import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { LLMTracingModule } from 'src/engine/core-modules/llm-tracing/llm-tracing.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
@Module({

View File

@ -11,8 +11,8 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
import { sqlGenerationPromptTemplate } from 'src/engine/core-modules/ai-sql-query/ai-sql-query.prompt-templates';
import { AISQLQueryResult } from 'src/engine/core-modules/ai-sql-query/dtos/ai-sql-query-result.dto';
import { LLMChatModelService } from 'src/engine/integrations/llm-chat-model/llm-chat-model.service';
import { LLMTracingService } from 'src/engine/integrations/llm-tracing/llm-tracing.service';
import { LLMChatModelService } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.service';
import { LLMTracingService } from 'src/engine/core-modules/llm-tracing/llm-tracing.service';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { AnalyticsResolver } from './analytics.resolver';
import { AnalyticsService } from './analytics.service';

View File

@ -8,7 +8,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { AnalyticsService } from './analytics.service';
import { Analytics } from './analytics.entity';

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { AnalyticsService } from './analytics.service';

View File

@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
type CreateEventInput = {
type: string;

View File

@ -6,7 +6,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { CaptchaGuard } from 'src/engine/integrations/captcha/captcha.guard';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { AuthResolver } from './auth.resolver';

View File

@ -22,7 +22,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { CaptchaGuard } from 'src/engine/integrations/captcha/captcha.guard';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { ChallengeInput } from './dto/challenge.input';
import { ImpersonateInput } from './dto/impersonate.input';

View File

@ -20,7 +20,7 @@ import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)

View File

@ -7,7 +7,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(

View File

@ -7,7 +7,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {

View File

@ -7,7 +7,7 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleStrategy } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class GoogleProviderEnabledGuard implements CanActivate {

View File

@ -7,7 +7,7 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { MicrosoftStrategy } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class MicrosoftProviderEnabledGuard implements CanActivate {

View File

@ -5,8 +5,8 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';

View File

@ -9,7 +9,7 @@ import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface';
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
import {
AppToken,
@ -36,8 +36,8 @@ import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-mem
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { TokenService } from './token.service';

View File

@ -3,10 +3,10 @@ import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {

View File

@ -8,7 +8,7 @@ import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
describe('SignInUpService', () => {
let service: SignInUpService;

View File

@ -25,7 +25,7 @@ import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { getImageBufferFromUrl } from 'src/utils/image';
export type SignInUpServiceInput = {

View File

@ -14,8 +14,8 @@ import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.aut
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { TokenService } from './token.service';

View File

@ -42,8 +42,8 @@ import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';

View File

@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-google-oauth20';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type GoogleAPIScopeConfig = {
isCalendarEnabled?: boolean;

View File

@ -4,7 +4,7 @@ import { VerifyCallback } from 'passport-google-oauth20';
import { GoogleAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type GoogleAPIScopeConfig = {
isCalendarEnabled?: boolean;

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { GoogleAPIsOauthCommonStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-common.auth.strategy';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type GoogleAPIScopeConfig = {
isCalendarEnabled?: boolean;

View File

@ -4,7 +4,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type GoogleRequest = Omit<
Request,

View File

@ -13,7 +13,7 @@ import {
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';

View File

@ -8,7 +8,7 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export type MicrosoftRequest = Omit<
Request,

View File

@ -3,9 +3,9 @@ import { Logger, Scope } from '@nestjs/common';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
export type UpdateSubscriptionJobData = { workspaceId: string };
@Processor({

View File

@ -5,11 +5,11 @@ import {
UpdateSubscriptionJob,
UpdateSubscriptionJobData,
} from 'src/engine/core-modules/billing/jobs/update-subscription.job';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event';
import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/workspace-event.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';

View File

@ -9,7 +9,7 @@ import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.ser
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { assert } from 'src/utils/assert';
export enum WebhookEvent {

View File

@ -18,7 +18,7 @@ import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.ser
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class BillingSubscriptionService {

View File

@ -6,7 +6,7 @@ import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/bil
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class BillingService {

View File

@ -7,7 +7,7 @@ import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/ava
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeService {

View File

@ -0,0 +1,51 @@
import { CacheModuleOptions } from '@nestjs/common';
import { redisStore } from 'cache-manager-redis-yet';
import { CacheStorageType } from 'src/engine/core-modules/cache-storage/types/cache-storage-type.enum';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export const cacheStorageModuleFactory = (
environmentService: EnvironmentService,
): CacheModuleOptions => {
const cacheStorageType = environmentService.get('CACHE_STORAGE_TYPE');
const cacheStorageTtl = environmentService.get('CACHE_STORAGE_TTL');
const cacheModuleOptions: CacheModuleOptions = {
isGlobal: true,
ttl: cacheStorageTtl * 1000,
};
switch (cacheStorageType) {
case CacheStorageType.Memory: {
return cacheModuleOptions;
}
case CacheStorageType.Redis: {
const host = environmentService.get('REDIS_HOST');
const port = environmentService.get('REDIS_PORT');
if (!(host && port)) {
throw new Error(
`${cacheStorageType} cache storage requires host: ${host} and port: ${port} to be defined, check your .env file`,
);
}
const username = environmentService.get('REDIS_USERNAME');
const password = environmentService.get('REDIS_PASSWORD');
return {
...cacheModuleOptions,
store: redisStore,
socket: {
host,
port,
username,
password,
},
};
}
default:
throw new Error(
`Invalid cache-storage (${cacheStorageType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,39 @@
import { Module, Global, Inject, OnModuleDestroy } from '@nestjs/common';
import { CacheModule, CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { ConfigModule } from '@nestjs/config';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/cache-storage.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { cacheStorageModuleFactory } from 'src/engine/core-modules/cache-storage/cache-storage.module-factory';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
@Global()
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
useFactory: cacheStorageModuleFactory,
inject: [EnvironmentService],
}),
],
providers: [
...Object.values(CacheStorageNamespace).map((cacheStorageNamespace) => ({
provide: cacheStorageNamespace,
useFactory: (cacheManager: Cache) => {
return new CacheStorageService(cacheManager, cacheStorageNamespace);
},
inject: [CACHE_MANAGER],
})),
],
exports: [...Object.values(CacheStorageNamespace)],
})
export class CacheStorageModule implements OnModuleDestroy {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async onModuleDestroy() {
if ((this.cacheManager.store as any)?.name === 'redis') {
await (this.cacheManager.store as any).client.quit();
}
}
}

View File

@ -0,0 +1,73 @@
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { RedisCache } from 'cache-manager-redis-yet';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
@Injectable()
export class CacheStorageService {
constructor(
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
private readonly namespace: CacheStorageNamespace,
) {}
async get<T>(key: string): Promise<T | undefined> {
return this.cache.get(`${this.namespace}:${key}`);
}
async set<T>(key: string, value: T, ttl?: number) {
return this.cache.set(`${this.namespace}:${key}`, value, ttl);
}
async del(key: string) {
return this.cache.del(`${this.namespace}:${key}`);
}
async setAdd(key: string, value: string[]) {
if (value.length === 0) {
return;
}
if (this.isRedisCache()) {
return (this.cache as RedisCache).store.client.sAdd(
`${this.namespace}:${key}`,
value,
);
}
this.get(key).then((res: string[]) => {
if (res) {
this.set(key, [...res, ...value]);
} else {
this.set(key, value);
}
});
}
async setPop(key: string, size = 1) {
if (this.isRedisCache()) {
return (this.cache as RedisCache).store.client.sPop(
`${this.namespace}:${key}`,
size,
);
}
return this.get(key).then((res: string[]) => {
if (res) {
this.set(key, res.slice(0, -size));
return res.slice(-size);
}
return [];
});
}
async flush() {
return this.cache.reset();
}
private isRedisCache() {
return (this.cache.store as any)?.name === 'redis';
}
}

View File

@ -0,0 +1,9 @@
import { Inject } from '@nestjs/common';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
export const InjectCacheStorage = (
cacheStorageNamespace: CacheStorageNamespace,
) => {
return Inject(cacheStorageNamespace);
};

View File

@ -0,0 +1,5 @@
export enum CacheStorageNamespace {
ModuleMessaging = 'module:messaging',
ModuleCalendar = 'module:calendar',
EngineWorkspace = 'engine:workspace',
}

View File

@ -0,0 +1,4 @@
export enum CacheStorageType {
Memory = 'memory',
Redis = 'redis',
}

View File

@ -0,0 +1 @@
export const CAPTCHA_DRIVER = Symbol('CAPTCHA_DRIVER');

View File

@ -0,0 +1,28 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { CaptchaService } from 'src/engine/core-modules/captcha/captcha.service';
@Injectable()
export class CaptchaGuard implements CanActivate {
constructor(private captchaService: CaptchaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const { captchaToken: token } = ctx.getArgs();
const result = await this.captchaService.validate(token || '');
if (result.success) return true;
else
throw new BadRequestException(
'Invalid Captcha, please try another device',
);
}
}

View File

@ -0,0 +1,31 @@
import {
CaptchaDriverOptions,
CaptchaModuleOptions,
} from 'src/engine/core-modules/captcha/interfaces';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export const captchaModuleFactory = (
environmentService: EnvironmentService,
): CaptchaModuleOptions | undefined => {
const driver = environmentService.get('CAPTCHA_DRIVER');
const siteKey = environmentService.get('CAPTCHA_SITE_KEY');
const secretKey = environmentService.get('CAPTCHA_SECRET_KEY');
if (!driver) {
return;
}
if (!siteKey || !secretKey) {
throw new Error('Captcha driver requires site key and secret key');
}
const captchaOptions: CaptchaDriverOptions = {
siteKey,
secretKey,
};
return {
type: driver,
options: captchaOptions,
};
};

View File

@ -0,0 +1,42 @@
import { DynamicModule, Global } from '@nestjs/common';
import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/captcha.constants';
import { CaptchaService } from 'src/engine/core-modules/captcha/captcha.service';
import { GoogleRecaptchaDriver } from 'src/engine/core-modules/captcha/drivers/google-recaptcha.driver';
import { TurnstileDriver } from 'src/engine/core-modules/captcha/drivers/turnstile.driver';
import {
CaptchaDriverType,
CaptchaModuleAsyncOptions,
} from 'src/engine/core-modules/captcha/interfaces';
@Global()
export class CaptchaModule {
static forRoot(options: CaptchaModuleAsyncOptions): DynamicModule {
const provider = {
provide: CAPTCHA_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
if (!config) {
return;
}
switch (config.type) {
case CaptchaDriverType.GoogleRecaptcha:
return new GoogleRecaptchaDriver(config.options);
case CaptchaDriverType.Turnstile:
return new TurnstileDriver(config.options);
default:
return;
}
},
inject: options.inject || [],
};
return {
module: CaptchaModule,
providers: [CaptchaService, provider],
exports: [CaptchaService],
};
}
}

View File

@ -0,0 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
import { CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface';
import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/captcha.constants';
import { CaptchaValidateResult } from 'src/engine/core-modules/captcha/interfaces';
@Injectable()
export class CaptchaService implements CaptchaDriver {
constructor(@Inject(CAPTCHA_DRIVER) private driver: CaptchaDriver) {}
async validate(token: string): Promise<CaptchaValidateResult> {
if (this.driver) {
return await this.driver.validate(token);
} else {
return {
success: true,
};
}
}
}

View File

@ -0,0 +1,39 @@
import axios, { AxiosInstance } from 'axios';
import { CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface';
import { CaptchaServerResponse } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-server-response';
import {
CaptchaDriverOptions,
CaptchaValidateResult,
} from 'src/engine/core-modules/captcha/interfaces';
export class GoogleRecaptchaDriver implements CaptchaDriver {
private readonly siteKey: string;
private readonly secretKey: string;
private readonly httpService: AxiosInstance;
constructor(private options: CaptchaDriverOptions) {
this.siteKey = options.siteKey;
this.secretKey = options.secretKey;
this.httpService = axios.create({
baseURL: 'https://www.google.com/recaptcha/api/siteverify',
});
}
async validate(token: string): Promise<CaptchaValidateResult> {
const formData = new URLSearchParams({
secret: this.secretKey,
response: token,
});
const response = await this.httpService.post('', formData);
const responseData = response.data as CaptchaServerResponse;
return {
success: responseData.success,
...(!responseData.success && {
error: responseData['error-codes']?.[0] ?? 'Captcha Error',
}),
};
}
}

View File

@ -0,0 +1,5 @@
import { CaptchaValidateResult } from 'src/engine/core-modules/captcha/interfaces';
export interface CaptchaDriver {
validate(token: string): Promise<CaptchaValidateResult>;
}

View File

@ -0,0 +1,6 @@
export type CaptchaServerResponse = {
success: boolean;
challenge_ts: string;
hostname: string;
'error-codes': string[];
};

View File

@ -0,0 +1,39 @@
import axios, { AxiosInstance } from 'axios';
import { CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface';
import { CaptchaServerResponse } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-server-response';
import {
CaptchaDriverOptions,
CaptchaValidateResult,
} from 'src/engine/core-modules/captcha/interfaces';
export class TurnstileDriver implements CaptchaDriver {
private readonly siteKey: string;
private readonly secretKey: string;
private readonly httpService: AxiosInstance;
constructor(private options: CaptchaDriverOptions) {
this.siteKey = options.siteKey;
this.secretKey = options.secretKey;
this.httpService = axios.create({
baseURL: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
});
}
async validate(token: string): Promise<CaptchaValidateResult> {
const formData = new URLSearchParams({
secret: this.secretKey,
response: token,
});
const response = await this.httpService.post('', formData);
const responseData = response.data as CaptchaServerResponse;
return {
success: responseData.success,
...(!responseData.success && {
error: responseData['error-codes']?.[0] ?? 'Captcha Error',
}),
};
}
}

View File

@ -0,0 +1,39 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import { registerEnumType } from '@nestjs/graphql';
export enum CaptchaDriverType {
GoogleRecaptcha = 'google-recaptcha',
Turnstile = 'turnstile',
}
registerEnumType(CaptchaDriverType, {
name: 'CaptchaDriverType',
});
export type CaptchaDriverOptions = {
siteKey: string;
secretKey: string;
};
export interface GoogleRecaptchaDriverFactoryOptions {
type: CaptchaDriverType.GoogleRecaptcha;
options: CaptchaDriverOptions;
}
export interface TurnstileDriverFactoryOptions {
type: CaptchaDriverType.Turnstile;
options: CaptchaDriverOptions;
}
export type CaptchaModuleOptions =
| GoogleRecaptchaDriverFactoryOptions
| TurnstileDriverFactoryOptions;
export type CaptchaModuleAsyncOptions = {
useFactory: (
...args: any[]
) => CaptchaModuleOptions | Promise<CaptchaModuleOptions> | undefined;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;
export type CaptchaValidateResult = { success: boolean; error?: string };

View File

@ -0,0 +1 @@
export * from 'src/engine/core-modules/captcha/interfaces/captcha.interface';

View File

@ -1,6 +1,6 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { CaptchaDriverType } from 'src/engine/integrations/captcha/interfaces';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
@ObjectType()
class AuthProviders {

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ClientConfigResolver } from './client-config.resolver';

View File

@ -1,6 +1,6 @@
import { Resolver, Query } from '@nestjs/graphql';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ClientConfig } from './client-config.entity';

View File

@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AISQLQueryModule } from 'src/engine/core-modules/ai-sql-query/ai-sql-query.module';
@ -15,10 +17,32 @@ import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkflowTriggerApiModule } from 'src/engine/core-modules/workflow/workflow-trigger-api.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { fileStorageModuleFactory } from 'src/engine/core-modules/file-storage/file-storage.module-factory';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { LoggerModule } from 'src/engine/core-modules/logger/logger.module';
import { loggerModuleFactory } from 'src/engine/core-modules/logger/logger.module-factory';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { messageQueueModuleFactory } from 'src/engine/core-modules/message-queue/message-queue.module-factory';
import { ExceptionHandlerModule } from 'src/engine/core-modules/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/engine/core-modules/exception-handler/exception-handler.module-factory';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { emailModuleFactory } from 'src/engine/core-modules/email/email.module-factory';
import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
import { captchaModuleFactory } from 'src/engine/core-modules/captcha/captcha.module-factory';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module';
import { llmChatModelModuleFactory } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module-factory';
import { LLMTracingModule } from 'src/engine/core-modules/llm-tracing/llm-tracing.module';
import { llmTracingModuleFactory } from 'src/engine/core-modules/llm-tracing/llm-tracing.module-factory';
import { ServerlessModule } from 'src/engine/core-modules/serverless/serverless.module';
import { serverlessModuleFactory } from 'src/engine/core-modules/serverless/serverless-module.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { AnalyticsModule } from './analytics/analytics.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { FileModule } from './file/file.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { AnalyticsModule } from './analytics/analytics.module';
@Module({
imports: [
@ -40,6 +64,47 @@ import { FileModule } from './file/file.module';
WorkflowTriggerApiModule,
WorkspaceEventEmitterModule,
ActorModule,
EnvironmentModule.forRoot({}),
FileStorageModule.forRootAsync({
useFactory: fileStorageModuleFactory,
inject: [EnvironmentService],
}),
LoggerModule.forRootAsync({
useFactory: loggerModuleFactory,
inject: [EnvironmentService],
}),
MessageQueueModule.registerAsync({
useFactory: messageQueueModuleFactory,
inject: [EnvironmentService],
}),
ExceptionHandlerModule.forRootAsync({
useFactory: exceptionHandlerModuleFactory,
inject: [EnvironmentService, HttpAdapterHost],
}),
EmailModule.forRoot({
useFactory: emailModuleFactory,
inject: [EnvironmentService],
}),
CaptchaModule.forRoot({
useFactory: captchaModuleFactory,
inject: [EnvironmentService],
}),
EventEmitterModule.forRoot({
wildcard: true,
}),
CacheStorageModule,
LLMChatModelModule.forRoot({
useFactory: llmChatModelModuleFactory,
inject: [EnvironmentService],
}),
LLMTracingModule.forRoot({
useFactory: llmTracingModuleFactory,
inject: [EnvironmentService],
}),
ServerlessModule.forRootAsync({
useFactory: serverlessModuleFactory,
inject: [EnvironmentService, FileStorageService],
}),
],
exports: [
AnalyticsModule,

View File

@ -0,0 +1,5 @@
import { SendMailOptions } from 'nodemailer';
export interface EmailDriver {
send(sendMailOptions: SendMailOptions): Promise<void>;
}

View File

@ -0,0 +1,20 @@
import { Logger } from '@nestjs/common';
import { SendMailOptions } from 'nodemailer';
import { EmailDriver } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
export class LoggerDriver implements EmailDriver {
private readonly logger = new Logger(LoggerDriver.name);
async send(sendMailOptions: SendMailOptions): Promise<void> {
const info =
`Sent email to: ${sendMailOptions.to}\n` +
`From: ${sendMailOptions.from}\n` +
`Subject: ${sendMailOptions.subject}\n` +
`Content Text: ${sendMailOptions.text}\n` +
`Content HTML: ${sendMailOptions.html}`;
this.logger.log(info);
}
}

View File

@ -0,0 +1,26 @@
import { Logger } from '@nestjs/common';
import { createTransport, Transporter, SendMailOptions } from 'nodemailer';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
import { EmailDriver } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
export class SmtpDriver implements EmailDriver {
private readonly logger = new Logger(SmtpDriver.name);
private transport: Transporter;
constructor(options: SMTPConnection.Options) {
this.transport = createTransport(options);
}
async send(sendMailOptions: SendMailOptions): Promise<void> {
this.transport
.sendMail(sendMailOptions)
.then(() =>
this.logger.log(`Email to '${sendMailOptions.to}' successfully sent`),
)
.catch((err) =>
this.logger.error(`sending email to '${sendMailOptions.to}': ${err}`),
);
}
}

View File

@ -0,0 +1,16 @@
import { SendMailOptions } from 'nodemailer';
import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
@Processor(MessageQueue.emailQueue)
export class EmailSenderJob {
constructor(private readonly emailSenderService: EmailSenderService) {}
@Process(EmailSenderJob.name)
async handle(data: SendMailOptions): Promise<void> {
await this.emailSenderService.send(data);
}
}

View File

@ -0,0 +1,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { SendMailOptions } from 'nodemailer';
import { EmailDriver } from 'src/engine/core-modules/email/drivers/interfaces/email-driver.interface';
import { EMAIL_DRIVER } from 'src/engine/core-modules/email/email.constants';
@Injectable()
export class EmailSenderService implements EmailDriver {
constructor(@Inject(EMAIL_DRIVER) private driver: EmailDriver) {}
async send(sendMailOptions: SendMailOptions): Promise<void> {
await this.driver.send(sendMailOptions);
}
}

View File

@ -0,0 +1 @@
export const EMAIL_DRIVER = Symbol('EMAIL_DRIVER');

View File

@ -0,0 +1,40 @@
import {
EmailDriver,
EmailModuleOptions,
} from 'src/engine/core-modules/email/interfaces/email.interface';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export const emailModuleFactory = (
environmentService: EnvironmentService,
): EmailModuleOptions => {
const driver = environmentService.get('EMAIL_DRIVER');
switch (driver) {
case EmailDriver.Logger: {
return;
}
case EmailDriver.Smtp: {
const host = environmentService.get('EMAIL_SMTP_HOST');
const port = environmentService.get('EMAIL_SMTP_PORT');
const user = environmentService.get('EMAIL_SMTP_USER');
const pass = environmentService.get('EMAIL_SMTP_PASSWORD');
if (!(host && port)) {
throw new Error(
`${driver} email driver requires host: ${host} and port: ${port} to be defined, check your .env file`,
);
}
const auth = user && pass ? { user, pass } : undefined;
if (auth) {
return { host, port, auth };
}
return { host, port };
}
default:
throw new Error(`Invalid email driver (${driver}), check your .env file`);
}
};

View File

@ -0,0 +1,30 @@
import { DynamicModule, Global } from '@nestjs/common';
import { EmailModuleAsyncOptions } from 'src/engine/core-modules/email/interfaces/email.interface';
import { EMAIL_DRIVER } from 'src/engine/core-modules/email/email.constants';
import { LoggerDriver } from 'src/engine/core-modules/email/drivers/logger.driver';
import { SmtpDriver } from 'src/engine/core-modules/email/drivers/smtp.driver';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EmailSenderService } from 'src/engine/core-modules/email/email-sender.service';
@Global()
export class EmailModule {
static forRoot(options: EmailModuleAsyncOptions): DynamicModule {
const provider = {
provide: EMAIL_DRIVER,
useFactory: (...args: any[]) => {
const config = options.useFactory(...args);
return config ? new SmtpDriver(config) : new LoggerDriver();
},
inject: options.inject || [],
};
return {
module: EmailModule,
providers: [EmailSenderService, EmailService, provider],
exports: [EmailSenderService, EmailService],
};
}
}

View File

@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { SendMailOptions } from 'nodemailer';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@Injectable()
export class EmailService {
constructor(
@InjectMessageQueue(MessageQueue.emailQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async send(sendMailOptions: SendMailOptions): Promise<void> {
await this.messageQueueService.add<SendMailOptions>(
EmailSenderJob.name,
sendMailOptions,
{ retryLimit: 3 },
);
}
}

View File

@ -0,0 +1,15 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import SMTPConnection from 'nodemailer/lib/smtp-connection';
export enum EmailDriver {
Logger = 'logger',
Smtp = 'smtp',
}
export type EmailModuleOptions = SMTPConnection.Options | undefined;
export type EmailModuleAsyncOptions = {
useFactory: (...args: any[]) => EmailModuleOptions;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -0,0 +1,66 @@
import { plainToClass } from 'class-transformer';
import { CastToLogLevelArray } from 'src/engine/core-modules/environment/decorators/cast-to-log-level-array.decorator';
class TestClass {
@CastToLogLevelArray()
logLevels?: any;
}
describe('CastToLogLevelArray Decorator', () => {
it('should cast "log" to ["log"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'log' });
expect(transformedClass.logLevels).toStrictEqual(['log']);
});
it('should cast "error" to ["error"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'error' });
expect(transformedClass.logLevels).toStrictEqual(['error']);
});
it('should cast "warn" to ["warn"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'warn' });
expect(transformedClass.logLevels).toStrictEqual(['warn']);
});
it('should cast "debug" to ["debug"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'debug' });
expect(transformedClass.logLevels).toStrictEqual(['debug']);
});
it('should cast "verbose" to ["verbose"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'verbose' });
expect(transformedClass.logLevels).toStrictEqual(['verbose']);
});
it('should cast "verbose,error,warn" to ["verbose", "error", "warn"]', () => {
const transformedClass = plainToClass(TestClass, {
logLevels: 'verbose,error,warn',
});
expect(transformedClass.logLevels).toStrictEqual([
'verbose',
'error',
'warn',
]);
});
it('should cast "toto" to undefined', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'toto' });
expect(transformedClass.logLevels).toBeUndefined();
});
it('should cast "verbose,error,toto" to undefined', () => {
const transformedClass = plainToClass(TestClass, {
logLevels: 'verbose,error,toto',
});
expect(transformedClass.logLevels).toBeUndefined();
});
});

View File

@ -0,0 +1,58 @@
import { plainToClass } from 'class-transformer';
import { CastToPositiveNumber } from 'src/engine/core-modules/environment/decorators/cast-to-positive-number.decorator';
class TestClass {
@CastToPositiveNumber()
numberProperty?: any;
}
describe('CastToPositiveNumber Decorator', () => {
it('should cast number to number', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: 123 });
expect(transformedClass.numberProperty).toBe(123);
});
it('should cast string to number', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: '123' });
expect(transformedClass.numberProperty).toBe(123);
});
it('should cast null to undefined', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: null });
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast negative number to undefined', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: -12 });
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast undefined to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: undefined,
});
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast NaN string to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: 'toto',
});
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast a negative string to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: '-123',
});
expect(transformedClass.numberProperty).toBe(undefined);
});
});

View File

@ -0,0 +1,18 @@
import { Transform } from 'class-transformer';
export const CastToBoolean = () =>
Transform(({ value }: { value: string }) => toBoolean(value));
const toBoolean = (value: any) => {
if (typeof value === 'boolean') {
return value;
}
if (['true', 'on', 'yes', '1'].includes(value.toLowerCase())) {
return true;
}
if (['false', 'off', 'no', '0'].includes(value.toLowerCase())) {
return false;
}
return undefined;
};

View File

@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
export const CastToLogLevelArray = () =>
Transform(({ value }: { value: string }) => toLogLevelArray(value));
const toLogLevelArray = (value: any) => {
if (typeof value === 'string') {
const rawLogLevels = value.split(',').map((level) => level.trim());
const isInvalid = rawLogLevels.some(
(level) => !['log', 'error', 'warn', 'debug', 'verbose'].includes(level),
);
if (!isInvalid) {
return rawLogLevels;
}
}
return undefined;
};

View File

@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
export const CastToPositiveNumber = () =>
Transform(({ value }: { value: string }) => toNumber(value));
const toNumber = (value: any) => {
if (typeof value === 'number') {
return value >= 0 ? value : undefined;
}
if (typeof value === 'string') {
return isNaN(+value) ? undefined : toNumber(+value);
}
return undefined;
};

View File

@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
export const CastToStringArray = () =>
Transform(({ value }: { value: string }) => toStringArray(value));
const toStringArray = (value: any) => {
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim());
}
return undefined;
};

View File

@ -0,0 +1,27 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsAWSRegionConstraint implements ValidatorConstraintInterface {
validate(region: string) {
const regex = /^[a-z]{2}-[a-z]+-\d{1}$/;
return regex.test(region); // Returns true if region matches regex
}
}
export const IsAWSRegion =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsAWSRegionConstraint,
});
};

View File

@ -0,0 +1,28 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsDurationConstraint implements ValidatorConstraintInterface {
validate(duration: string) {
const regex =
/^-?[0-9]+(.[0-9]+)?(m(illiseconds?)?|s(econds?)?|h((ou)?rs?)?|d(ays?)?|w(eeks?)?|M(onths?)?|y(ears?)?)?$/;
return regex.test(duration); // Returns true if duration matches regex
}
}
export const IsDuration =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsDurationConstraint,
});
};

View File

@ -0,0 +1,32 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
export const IsStrictlyLowerThan = (
property: string,
validationOptions?: ValidationOptions,
) => {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'isStrictlyLowerThan',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return (
typeof value === 'number' &&
typeof relatedValue === 'number' &&
value < relatedValue
);
},
},
});
};
};

View File

@ -0,0 +1,446 @@
import { LogLevel } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUrl,
Max,
Min,
ValidateIf,
validateSync,
} from 'class-validator';
import { EmailDriver } from 'src/engine/core-modules/email/interfaces/email.interface';
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
import { LLMChatModelDriver } from 'src/engine/core-modules/llm-chat-model/interfaces/llm-chat-model.interface';
import { LLMTracingDriver } from 'src/engine/core-modules/llm-tracing/interfaces/llm-tracing.interface';
import { AwsRegion } from 'src/engine/core-modules/environment/interfaces/aws-region.interface';
import { SupportDriver } from 'src/engine/core-modules/environment/interfaces/support.interface';
import { IsDuration } from 'src/engine/core-modules/environment/decorators/is-duration.decorator';
import { IsAWSRegion } from 'src/engine/core-modules/environment/decorators/is-aws-region.decorator';
import { CastToPositiveNumber } from 'src/engine/core-modules/environment/decorators/cast-to-positive-number.decorator';
import { CastToLogLevelArray } from 'src/engine/core-modules/environment/decorators/cast-to-log-level-array.decorator';
import { CastToBoolean } from 'src/engine/core-modules/environment/decorators/cast-to-boolean.decorator';
import { CacheStorageType } from 'src/engine/core-modules/cache-storage/types/cache-storage-type.enum';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CastToStringArray } from 'src/engine/core-modules/environment/decorators/cast-to-string-array.decorator';
import { IsStrictlyLowerThan } from 'src/engine/core-modules/environment/decorators/is-strictly-lower-than.decorator';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
import { LoggerDriverType } from 'src/engine/core-modules/logger/interfaces';
import { MessageQueueDriverType } from 'src/engine/core-modules/message-queue/interfaces';
import { ServerlessDriverType } from 'src/engine/core-modules/serverless/serverless.interface';
import { assert } from 'src/utils/assert';
export class EnvironmentVariables {
// Misc
@CastToBoolean()
@IsOptional()
@IsBoolean()
DEBUG_MODE = false;
@IsEnum(NodeEnvironment)
@IsString()
NODE_ENV: NodeEnvironment = NodeEnvironment.development;
@CastToPositiveNumber()
@IsOptional()
@IsNumber()
@Min(0)
@Max(65535)
DEBUG_PORT = 9000;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_BILLING_ENABLED = false;
@IsString()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_PLAN_REQUIRED_LINK: string;
@IsString()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID: string;
@IsNumber()
@CastToPositiveNumber()
@IsOptional()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_TRIAL_DURATION_IN_DAYS = 7;
@IsString()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_API_KEY: string;
@IsString()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_WEBHOOK_SECRET: string;
@CastToBoolean()
@IsOptional()
@IsBoolean()
TELEMETRY_ENABLED = true;
@CastToPositiveNumber()
@IsNumber()
@IsOptional()
PORT = 3000;
// Database
@IsDefined()
@IsUrl({
protocols: ['postgres'],
require_tld: false,
allow_underscores: true,
})
PG_DATABASE_URL: string;
@CastToBoolean()
@IsBoolean()
@IsOptional()
PG_SSL_ALLOW_SELF_SIGNED = false;
// Frontend URL
@IsUrl({ require_tld: false })
FRONT_BASE_URL: string;
// Server URL
@IsUrl({ require_tld: false })
@IsOptional()
SERVER_URL: string;
// Json Web Token
@IsString()
ACCESS_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
ACCESS_TOKEN_EXPIRES_IN = '30m';
@IsString()
REFRESH_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
REFRESH_TOKEN_EXPIRES_IN = '60d';
@IsDuration()
@IsOptional()
REFRESH_TOKEN_COOL_DOWN = '1m';
@IsString()
LOGIN_TOKEN_SECRET = '30m';
@IsDuration()
@IsOptional()
LOGIN_TOKEN_EXPIRES_IN = '15m';
@IsString()
@IsOptional()
FILE_TOKEN_SECRET = 'random_string';
@IsDuration()
@IsOptional()
FILE_TOKEN_EXPIRES_IN = '1d';
// Auth
@IsUrl({ require_tld: false })
@IsOptional()
FRONT_AUTH_CALLBACK_URL: string;
@CastToBoolean()
@IsOptional()
@IsBoolean()
AUTH_PASSWORD_ENABLED = true;
@CastToBoolean()
@IsOptional()
@IsBoolean()
@ValidateIf((env) => env.AUTH_PASSWORD_ENABLED)
SIGN_IN_PREFILLED = false;
@CastToBoolean()
@IsOptional()
@IsBoolean()
AUTH_MICROSOFT_ENABLED = false;
@IsString()
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_ID: string;
@IsString()
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_TENANT_ID: string;
@IsString()
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_SECRET: string;
@IsUrl({ require_tld: false })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CALLBACK_URL: string;
@CastToBoolean()
@IsOptional()
@IsBoolean()
AUTH_GOOGLE_ENABLED = false;
@IsString()
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_ID: string;
@IsString()
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_SECRET: string;
@IsUrl({ require_tld: false })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CALLBACK_URL: string;
// Custom Code Engine
@IsEnum(ServerlessDriverType)
@IsOptional()
SERVERLESS_TYPE: ServerlessDriverType = ServerlessDriverType.Local;
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsAWSRegion()
SERVERLESS_LAMBDA_REGION: AwsRegion;
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsString()
@IsOptional()
SERVERLESS_LAMBDA_ROLE: string;
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsString()
@IsOptional()
SERVERLESS_LAMBDA_ACCESS_KEY_ID: string;
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsString()
@IsOptional()
SERVERLESS_LAMBDA_SECRET_ACCESS_KEY: string;
// Storage
@IsEnum(StorageDriverType)
@IsOptional()
STORAGE_TYPE: StorageDriverType = StorageDriverType.Local;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsAWSRegion()
STORAGE_S3_REGION: AwsRegion;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsString()
STORAGE_S3_NAME: string;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsString()
@IsOptional()
STORAGE_S3_ENDPOINT: string;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsString()
@IsOptional()
STORAGE_S3_ACCESS_KEY_ID: string;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsString()
@IsOptional()
STORAGE_S3_SECRET_ACCESS_KEY: string;
@IsString()
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local)
STORAGE_LOCAL_PATH = '.local-storage';
// Support
@IsEnum(SupportDriver)
@IsOptional()
SUPPORT_DRIVER: SupportDriver = SupportDriver.None;
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
@IsString()
SUPPORT_FRONT_CHAT_ID: string;
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
@IsString()
SUPPORT_FRONT_HMAC_KEY: string;
@IsEnum(LoggerDriverType)
@IsOptional()
LOGGER_DRIVER: LoggerDriverType = LoggerDriverType.Console;
@CastToBoolean()
@IsBoolean()
@IsOptional()
LOGGER_IS_BUFFER_ENABLED = true;
@IsEnum(ExceptionHandlerDriver)
@IsOptional()
EXCEPTION_HANDLER_DRIVER: ExceptionHandlerDriver =
ExceptionHandlerDriver.Console;
@CastToLogLevelArray()
@IsOptional()
LOG_LEVELS: LogLevel[] = ['log', 'error', 'warn'];
@CastToStringArray()
@IsOptional()
DEMO_WORKSPACE_IDS: string[] = [];
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
)
@IsString()
SENTRY_DSN: string;
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
)
@IsString()
SENTRY_FRONT_DSN: string;
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
)
@IsString()
@IsOptional()
SENTRY_RELEASE: string;
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
)
@IsString()
@IsOptional()
SENTRY_ENVIRONMENT: string;
@IsDuration()
@IsOptional()
PASSWORD_RESET_TOKEN_EXPIRES_IN = '5m';
@CastToPositiveNumber()
@IsNumber()
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)
@IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', {
message:
'"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower that "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"',
})
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0)
WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 30;
@CastToPositiveNumber()
@IsNumber()
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0)
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 60;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_SIGN_UP_DISABLED = false;
@IsEnum(CaptchaDriverType)
@IsOptional()
CAPTCHA_DRIVER?: CaptchaDriverType;
@IsString()
@IsOptional()
CAPTCHA_SITE_KEY?: string;
@IsString()
@IsOptional()
CAPTCHA_SECRET_KEY?: string;
@CastToPositiveNumber()
@IsOptional()
@IsNumber()
MUTATION_MAXIMUM_AFFECTED_RECORDS = 100;
REDIS_HOST = '127.0.0.1';
@CastToPositiveNumber()
REDIS_PORT = 6379;
REDIS_USERNAME: string;
REDIS_PASSWORD: string;
API_TOKEN_EXPIRES_IN = '100y';
SHORT_TERM_TOKEN_EXPIRES_IN = '5m';
@CastToBoolean()
MESSAGING_PROVIDER_GMAIL_ENABLED = false;
MESSAGE_QUEUE_TYPE: string = MessageQueueDriverType.Sync;
EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com';
EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com';
EMAIL_FROM_NAME = 'Felix from Twenty';
EMAIL_DRIVER: EmailDriver = EmailDriver.Logger;
EMAIL_SMTP_HOST: string;
@CastToPositiveNumber()
EMAIL_SMTP_PORT = 587;
EMAIL_SMTP_USER: string;
EMAIL_SMTP_PASSWORD: string;
LLM_CHAT_MODEL_DRIVER: LLMChatModelDriver;
OPENAI_API_KEY: string;
LANGFUSE_SECRET_KEY: string;
LANGFUSE_PUBLIC_KEY: string;
LLM_TRACING_DRIVER: LLMTracingDriver = LLMTracingDriver.Console;
@CastToPositiveNumber()
API_RATE_LIMITING_TTL = 100;
@CastToPositiveNumber()
API_RATE_LIMITING_LIMIT = 500;
CACHE_STORAGE_TYPE: CacheStorageType = CacheStorageType.Memory;
@CastToPositiveNumber()
CACHE_STORAGE_TTL: number = 3600 * 24 * 7;
@CastToBoolean()
CALENDAR_PROVIDER_GOOGLE_ENABLED = false;
AUTH_GOOGLE_APIS_CALLBACK_URL: string;
CHROME_EXTENSION_ID: string;
@CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_LIMIT = 10;
// milliseconds
@CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000;
}
export const validate = (
config: Record<string, unknown>,
): EnvironmentVariables => {
const validatedConfig = plainToClass(EnvironmentVariables, config);
const errors = validateSync(validatedConfig);
assert(!errors.length, errors.toString());
return validatedConfig;
};

View File

@ -0,0 +1,8 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder({
moduleName: 'Environment',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ConfigurableModuleClass } from 'src/engine/core-modules/environment/environment.module-definition';
import { validate } from 'src/engine/core-modules/environment/environment-variables';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
validate,
}),
],
providers: [EnvironmentService],
exports: [EnvironmentService],
})
export class EnvironmentModule extends ConfigurableModuleClass {}

View File

@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
describe('EnvironmentService', () => {
let service: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EnvironmentService,
{
provide: ConfigService,
useValue: {},
},
],
}).compile();
service = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,17 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables';
@Injectable()
export class EnvironmentService {
constructor(private readonly configService: ConfigService) {}
get<T extends keyof EnvironmentVariables>(key: T): EnvironmentVariables[T] {
return this.configService.get<EnvironmentVariables[T]>(
key,
new EnvironmentVariables()[key],
);
}
}

View File

@ -0,0 +1 @@
export type AwsRegion = `${string}-${string}-${number}`;

View File

@ -0,0 +1,4 @@
export enum NodeEnvironment {
development = 'development',
production = 'production',
}

View File

@ -0,0 +1,4 @@
export enum SupportDriver {
None = 'none',
Front = 'front',
}

View File

@ -0,0 +1,7 @@
import { ObjectRecordBaseEvent } from 'src/engine/core-modules/event-emitter/types/object-record.base.event';
export class ObjectRecordCreateEvent<T> extends ObjectRecordBaseEvent {
properties: {
after: T;
};
}

View File

@ -0,0 +1,7 @@
import { ObjectRecordBaseEvent } from 'src/engine/core-modules/event-emitter/types/object-record.base.event';
export class ObjectRecordDeleteEvent<T> extends ObjectRecordBaseEvent {
properties: {
before: T;
};
}

View File

@ -0,0 +1,10 @@
import { ObjectRecordBaseEvent } from 'src/engine/core-modules/event-emitter/types/object-record.base.event';
export class ObjectRecordUpdateEvent<T> extends ObjectRecordBaseEvent {
properties: {
updatedFields: string[];
before: T;
after: T;
diff?: Partial<T>;
};
}

View File

@ -0,0 +1,14 @@
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
export class ObjectRecordBaseEvent {
recordId: string;
userId?: string;
workspaceMemberId?: string;
objectMetadata: ObjectMetadataInterface;
properties: any;
}
export class ObjectRecordBaseEventWithNameAndWorkspaceId extends ObjectRecordBaseEvent {
name: string;
workspaceId: string;
}

View File

@ -0,0 +1,119 @@
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
const mockObjectMetadata: ObjectMetadataInterface = {
id: '1',
nameSingular: 'Object',
namePlural: 'Objects',
labelSingular: 'Object',
labelPlural: 'Objects',
description: 'Test object metadata',
targetTableName: 'test_table',
fromRelations: [],
toRelations: [],
fields: [],
isSystem: false,
isCustom: false,
isActive: true,
isRemote: false,
isAuditLogged: true,
};
describe('objectRecordChangedValues', () => {
it('detects changes in scalar values correctly', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516m',
name: 'Original Name',
updatedAt: new Date().toString(),
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516m',
name: 'Updated Name',
updatedAt: new Date().toString(),
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name'],
mockObjectMetadata,
);
expect(result).toEqual({
name: { before: 'Original Name', after: 'Updated Name' },
});
});
});
it('ignores changes to the updatedAt field', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516d',
updatedAt: new Date('2020-01-01').toDateString(),
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516d',
updatedAt: new Date('2024-01-01').toDateString(),
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
[],
mockObjectMetadata,
);
expect(result).toEqual({});
});
it('returns an empty object when there are no changes', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516k',
name: 'Name',
value: 100,
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516k',
name: 'Name',
value: 100,
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name', 'value'],
mockObjectMetadata,
);
expect(result).toEqual({});
});
it('correctly handles a mix of changed, unchanged, and special case values', () => {
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516l',
name: 'Original',
status: 'active',
updatedAt: new Date(2020, 1, 1).toDateString(),
config: { theme: 'dark' },
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516l',
name: 'Updated',
status: 'active',
updatedAt: new Date(2021, 1, 1).toDateString(),
config: { theme: 'light' },
};
const expectedChanges = {
name: { before: 'Original', after: 'Updated' },
config: { before: { theme: 'dark' }, after: { theme: 'light' } },
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
['name', 'config', 'status'],
mockObjectMetadata,
);
expect(result).toEqual(expectedChanges);
});

View File

@ -0,0 +1,18 @@
import deepEqual from 'deep-equal';
import { Record } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
export const objectRecordChangedProperties = <
PRecord extends Partial<Record | BaseWorkspaceEntity> = Partial<Record>,
>(
oldRecord: PRecord,
newRecord: PRecord,
) => {
const changedProperties = Object.keys(newRecord).filter(
(key) => !deepEqual(oldRecord[key], newRecord[key]),
);
return changedProperties;
};

View File

@ -0,0 +1,41 @@
import deepEqual from 'deep-equal';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const objectRecordChangedValues = (
oldRecord: Partial<IRecord>,
newRecord: Partial<IRecord>,
updatedKeys: string[],
objectMetadata: ObjectMetadataInterface,
) => {
const fieldsByKey = new Map(
objectMetadata.fields.map((field) => [field.name, field]),
);
const changedValues = Object.keys(newRecord).reduce(
(acc, key) => {
const field = fieldsByKey.get(key);
const oldRecordValue = oldRecord[key];
const newRecordValue = newRecord[key];
if (
key === 'updatedAt' ||
!updatedKeys.includes(key) ||
field?.type === FieldMetadataType.RELATION ||
deepEqual(oldRecordValue, newRecordValue)
) {
return acc;
}
acc[key] = { before: oldRecordValue, after: newRecordValue };
return acc;
},
{} as Record<string, { before: any; after: any }>,
);
return changedValues;
};

View File

@ -0,0 +1,30 @@
export function objectRecordDiffMerge(
oldRecord: Record<string, any>,
newRecord: Record<string, any>,
): Record<string, any> {
const result: Record<string, any> = { diff: {} };
// Iterate over the keys in the oldRecord diff
Object.keys(oldRecord.diff ?? {}).forEach((key) => {
if (newRecord.diff && newRecord.diff[key]) {
// If the key also exists in the newRecord, merge the 'before' from the oldRecord and the 'after' from the newRecord
result.diff[key] = {
before: oldRecord.diff[key].before,
after: newRecord.diff[key].after,
};
} else {
// If the key does not exist in the newRecord, copy it as is from the oldRecord
result.diff[key] = oldRecord.diff[key];
}
});
// Iterate over the keys in the newRecord diff to catch any that weren't in the oldRecord
Object.keys(newRecord.diff ?? {}).forEach((key) => {
if (!result.diff[key]) {
// If the key was not already added from the oldRecord, add it from the newRecord
result.diff[key] = newRecord.diff[key];
}
});
return result;
}

View File

@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EXCEPTION_HANDLER_DRIVER } from 'src/engine/core-modules/exception-handler/exception-handler.constants';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
describe('ExceptionHandlerService', () => {
let service: ExceptionHandlerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ExceptionHandlerService,
{
provide: EXCEPTION_HANDLER_DRIVER,
useValue: {},
},
],
}).compile();
service = module.get<ExceptionHandlerService>(ExceptionHandlerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,28 @@
/* eslint-disable no-console */
import { ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-user.interface';
import { ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
import { ExceptionHandlerDriverInterface } from 'src/engine/core-modules/exception-handler/interfaces';
export class ExceptionHandlerConsoleDriver
implements ExceptionHandlerDriverInterface
{
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
) {
console.group('Exception Captured');
console.info(options);
console.error(exceptions);
console.groupEnd();
return [];
}
captureMessage(message: string, user?: ExceptionHandlerUser): void {
console.group('Message Captured');
console.info(user);
console.info(message);
console.groupEnd();
}
}

View File

@ -0,0 +1,111 @@
import * as Sentry from '@sentry/node';
import { ProfilingIntegration } from '@sentry/profiling-node';
import { ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-user.interface';
import { ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
import {
ExceptionHandlerDriverInterface,
ExceptionHandlerSentryDriverFactoryOptions,
} from 'src/engine/core-modules/exception-handler/interfaces';
export class ExceptionHandlerSentryDriver
implements ExceptionHandlerDriverInterface
{
constructor(options: ExceptionHandlerSentryDriverFactoryOptions['options']) {
Sentry.init({
environment: options.environment,
release: options.release,
dsn: options.dsn,
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Sentry.Integrations.Express({ app: options.serverInstance }),
new Sentry.Integrations.GraphQL(),
new Sentry.Integrations.Postgres(),
new ProfilingIntegration(),
],
tracesSampleRate: 0.1,
profilesSampleRate: 0.3,
debug: options.debug,
});
}
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
) {
const eventIds: string[] = [];
Sentry.withScope((scope) => {
if (options?.operation) {
scope.setTag('operation', options.operation.name);
scope.setTag('operationName', options.operation.name);
}
if (options?.document) {
scope.setExtra('document', options.document);
}
if (options?.user) {
scope.setUser({
id: options.user.id,
email: options.user.email,
firstName: options.user.firstName,
lastName: options.user.lastName,
workspaceId: options.user.workspaceId,
workspaceDisplayName: options.user.workspaceDisplayName,
});
}
for (const exception of exceptions) {
const errorPath = (exception.path ?? [])
.map((v: string | number) => (typeof v === 'number' ? '$index' : v))
.join(' > ');
if (errorPath) {
scope.addBreadcrumb({
category: 'execution-path',
message: errorPath,
level: 'debug',
});
}
const eventId = Sentry.captureException(exception, {
fingerprint: [
'graphql',
errorPath,
options?.operation?.name,
options?.operation?.type,
],
contexts: {
GraphQL: {
operationName: options?.operation?.name,
operationType: options?.operation?.type,
},
},
});
eventIds.push(eventId);
}
});
return eventIds;
}
captureMessage(message: string, user?: ExceptionHandlerUser) {
Sentry.captureMessage(message, (scope) => {
if (user) {
scope.setUser({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
workspaceId: user.workspaceId,
workspaceDisplayName: user.workspaceDisplayName,
});
}
return scope;
});
}
}

View File

@ -0,0 +1 @@
export const EXCEPTION_HANDLER_DRIVER = Symbol('EXCEPTION_HANDLER_DRIVER');

View File

@ -0,0 +1,14 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { ExceptionHandlerModuleOptions } from 'src/engine/core-modules/exception-handler/interfaces';
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<ExceptionHandlerModuleOptions>({
moduleName: 'ExceptionHandlerModule',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,42 @@
import { HttpAdapterHost } from '@nestjs/core';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OPTIONS_TYPE } from 'src/engine/core-modules/exception-handler/exception-handler.module-definition';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
/**
* ExceptionHandler Module factory
* @returns ExceptionHandlerModuleOptions
* @param environmentService
* @param adapterHost
*/
export const exceptionHandlerModuleFactory = async (
environmentService: EnvironmentService,
adapterHost: HttpAdapterHost,
): Promise<typeof OPTIONS_TYPE> => {
const driverType = environmentService.get('EXCEPTION_HANDLER_DRIVER');
switch (driverType) {
case ExceptionHandlerDriver.Console: {
return {
type: ExceptionHandlerDriver.Console,
};
}
case ExceptionHandlerDriver.Sentry: {
return {
type: ExceptionHandlerDriver.Sentry,
options: {
environment: environmentService.get('SENTRY_ENVIRONMENT'),
release: environmentService.get('SENTRY_RELEASE'),
dsn: environmentService.get('SENTRY_DSN') ?? '',
serverInstance: adapterHost.httpAdapter?.getInstance(),
debug: environmentService.get('DEBUG_MODE'),
},
};
}
default:
throw new Error(
`Invalid exception capturer driver type (${driverType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,59 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { EXCEPTION_HANDLER_DRIVER } from 'src/engine/core-modules/exception-handler/exception-handler.constants';
import {
ConfigurableModuleClass,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} from 'src/engine/core-modules/exception-handler/exception-handler.module-definition';
import { ExceptionHandlerConsoleDriver } from 'src/engine/core-modules/exception-handler/drivers/console.driver';
import { ExceptionHandlerSentryDriver } from 'src/engine/core-modules/exception-handler/drivers/sentry.driver';
@Global()
@Module({
providers: [ExceptionHandlerService],
exports: [ExceptionHandlerService],
})
export class ExceptionHandlerModule extends ConfigurableModuleClass {
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
const provider = {
provide: EXCEPTION_HANDLER_DRIVER,
useValue:
options.type === ExceptionHandlerDriver.Console
? new ExceptionHandlerConsoleDriver()
: new ExceptionHandlerSentryDriver(options.options),
};
const dynamicModule = super.forRoot(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
static forRootAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
const provider = {
provide: EXCEPTION_HANDLER_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options?.useFactory?.(...args);
if (!config) {
return null;
}
return config.type === ExceptionHandlerDriver.Console
? new ExceptionHandlerConsoleDriver()
: new ExceptionHandlerSentryDriver(config.options);
},
inject: options.inject || [],
};
const dynamicModule = super.forRootAsync(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
}

View File

@ -0,0 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
import { ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
import { ExceptionHandlerDriverInterface } from 'src/engine/core-modules/exception-handler/interfaces';
import { EXCEPTION_HANDLER_DRIVER } from 'src/engine/core-modules/exception-handler/exception-handler.constants';
@Injectable()
export class ExceptionHandlerService {
constructor(
@Inject(EXCEPTION_HANDLER_DRIVER)
private driver: ExceptionHandlerDriverInterface,
) {}
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
): string[] {
return this.driver.captureExceptions(exceptions, options);
}
}

View File

@ -0,0 +1,57 @@
import * as Sentry from '@sentry/node';
import {
handleStreamOrSingleExecutionResult,
Plugin,
getDocumentString,
} from '@envelop/core';
import { OperationDefinitionNode, Kind, print } from 'graphql';
import { GraphQLContext } from 'src/engine/api/graphql/graphql-config/graphql-config.service';
export const useSentryTracing = <
PluginContext extends GraphQLContext,
>(): Plugin<PluginContext> => {
return {
onExecute({ args }) {
const transactionName = args.operationName || 'Anonymous Operation';
const rootOperation = args.document.definitions.find(
(o) => o.kind === Kind.OPERATION_DEFINITION,
) as OperationDefinitionNode;
const operationType = rootOperation.operation;
const user = args.contextValue.user;
const workspace = args.contextValue.workspace;
const document = getDocumentString(args.document, print);
Sentry.setTags({
operationName: transactionName,
operation: operationType,
});
const scope = Sentry.getCurrentScope();
scope.setTransactionName(transactionName);
if (user) {
scope.setUser({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
workspaceId: workspace?.id,
workspaceDisplayName: workspace?.displayName,
});
}
if (document) {
scope.setExtra('document', document);
}
return {
onExecuteDone(payload) {
return handleStreamOrSingleExecutionResult(payload, () => {});
},
};
},
};
};

View File

@ -0,0 +1,10 @@
import { ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
import { ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-user.interface';
export interface ExceptionHandlerDriverInterface {
captureExceptions(
exceptions: ReadonlyArray<any>,
options?: ExceptionHandlerOptions,
): string[];
captureMessage(message: string, user?: ExceptionHandlerUser): void;
}

View File

@ -0,0 +1,12 @@
import { OperationTypeNode } from 'graphql';
import { ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-user.interface';
export interface ExceptionHandlerOptions {
operation?: {
type: OperationTypeNode;
name: string;
};
document?: string;
user?: ExceptionHandlerUser | null;
}

View File

@ -0,0 +1,8 @@
export interface ExceptionHandlerUser {
id?: string;
email?: string;
firstName?: string;
lastName?: string;
workspaceId?: string;
workspaceDisplayName?: string;
}

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